
Vertical onboarding stepper in a card: each of four steps opens a panel with an input, a primary action and a do-this-later option; skipped steps keep an open ring instead of a check.
blocks/stepper/workspace-setup/index.tsx
"use client"
import { useEffect, useRef, useState } from "react"
import { CheckIcon } from "@phosphor-icons/react"
import { cn } from "cn"
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
// The copy: change text here, not in the markup.
const COPY = {
title: "Set up your workspace",
body: "Four steps. Most teams finish in ten minutes.",
done: "Done",
skip: "Do this later",
ready: "Your workspace is ready.",
steps: [
{
title: "Name the workspace",
body: "Shown in the sidebar and on invites.",
action: "Save name",
placeholder: "Orbcore Design",
},
{
title: "Invite your team",
body: "Add people by email. Admins can approve requests later.",
action: "Send invites",
placeholder: "tomas@orbcore.dev, ines@orbcore.dev",
},
{
title: "Connect a repository",
body: "GitHub, GitLab or Bitbucket.",
action: "Connect",
placeholder: "github.com/orbcore/registry",
},
{
title: "Install the CLI",
body: "One command, any package manager.",
action: "I ran it",
placeholder: "pnpm dlx shadcn@latest init",
},
],
} as const
// Vertical: the list stacks, each separator runs down from its indicator, and
// the current step's panel opens under its own title.
export default function WorkspaceSetup() {
const [step, setStep] = useState(1)
const last = COPY.steps.length - 1
// Done is its own set: Do this later opens the next step without a check.
const [done, setDone] = useState<number[]>([0])
const finish = (i: number) => {
setDone((d) => (d.includes(i) ? d : [...d, i]))
setStep(Math.min(i + 1, last))
}
const ready = done.includes(last)
const readyRef = useRef<HTMLParagraphElement>(null)
useEffect(() => {
if (ready) readyRef.current?.focus()
}, [ready])
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader>
<CardTitle>{COPY.title}</CardTitle>
<CardDescription>{COPY.body}</CardDescription>
</CardHeader>
<CardContent>
<Stepper value={step} onValueChange={setStep} linear={false}>
<StepperList className="flex-col items-stretch">
{COPY.steps.map((s, i) => (
<StepperItem
key={s.title}
value={i}
className="flex-col items-stretch last:flex-1"
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
<StepperIndicator
className={cn(
"size-6 text-[11px]",
done.includes(i)
? "bg-primary text-primary-foreground ring-0"
: // Skipped: positionally completed, but it keeps the
// open ring so only done steps read as done.
"data-[state=completed]:bg-card data-[state=completed]:text-card-foreground data-[state=completed]:ring-1 data-[state=completed]:ring-border"
)}
>
{done.includes(i) ? (
<CheckIcon
weight="bold"
aria-hidden
className="size-3"
/>
) : (
<span>{i + 1}</span>
)}
</StepperIndicator>
<StepperSeparator className="mx-0 my-1 h-auto w-px flex-1 group-last/step:hidden" />
</div>
<div className="min-w-0 flex-1 pt-0.5 pb-6 group-last/step:pb-0">
<StepperTrigger className="h-auto justify-start px-0 py-0 text-left hover:bg-transparent">
<StepperTitle>{s.title}</StepperTitle>
</StepperTrigger>
<StepperContent
keepMounted
value={i}
className="mt-1 gap-3"
>
<StepperDescription>{s.body}</StepperDescription>
<Input
defaultValue={s.placeholder}
aria-label={s.title}
/>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => finish(i)}>
{i === last ? COPY.done : s.action}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setStep(Math.min(i + 1, last))}
>
{COPY.skip}
</Button>
</div>
</StepperContent>
</div>
</div>
</StepperItem>
))}
</StepperList>
</Stepper>
<p
ref={readyRef}
tabIndex={-1}
aria-live="polite"
className={cn("text-sm outline-none", ready ? "mt-4" : "sr-only")}
>
{ready ? COPY.ready : ""}
</p>
</CardContent>
</Card>
)
}
