Onboarding checklist
A setup list with a count, a thin progress bar, and a check you can toggle on every row.
A client component — dismissing is internal state, so the checklist removes itself and then calls onDismiss, the same way Banner does. Each circle is a native button with role=checkbox and aria-checked, named by the title beside it, so Space and Enter both toggle it without a key handler. Without onToggle the list is a read-only record: the circles become static, each carrying visually hidden Done or Not done text rather than leaving a focusable control that does nothing. A finished step is struck through and muted, and the bar turns green once every step is done. The div's own title and onToggle props are replaced by the ones documented here.
Install
npx shadcn@latest add @vibra/onboarding-checklistNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { OnboardingChecklist, type OnboardingStep } from "@/components/ui/onboarding-checklist"
const INITIAL: OnboardingStep[] = [
{ id: "workspace", title: "Name your workspace", done: true },
{ id: "source", title: "Connect a data source", description: "Postgres, a warehouse, or a CSV upload", done: true, href: "#sources" },
{
id: "dashboard",
title: "Build your first dashboard",
description: "Start from a template or a blank canvas",
done: false,
href: "#dashboards",
action: (
<Button variant="outline" size="sm">
Start
</Button>
),
},
{ id: "invite", title: "Invite a teammate", description: "Admins, editors, and viewers", done: false, href: "#members" },
{ id: "alerts", title: "Set up an alert", description: "Get told when a metric moves", done: false },
]
export default function OnboardingChecklistDemo() {
const [steps, setSteps] = React.useState(INITIAL)
return (
<OnboardingChecklist
className="w-full max-w-lg"
title="Finish setting up Vibra"
steps={steps}
dismissible
onToggle={(id, done) =>
setSteps((current) => current.map((step) => (step.id === id ? { ...step, done } : step)))
}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | OnboardingStep[] | — | One row each, in the order they should be done. |
| steps[].id | string | — | React key, and what onToggle is called with. |
| steps[].title | React.ReactNode | — | Names the step, and names its checkbox. |
| steps[].description | React.ReactNode | — | A quieter second line under the title. |
| steps[].done | boolean | — | Fills the circle, strikes the title through, and counts toward the bar. |
| steps[].action | React.ReactNode | — | A button or link at the end of the row — the thing that actually finishes the step. |
| steps[].href | string | — | Turns the step's title into a link to where the work happens. |
| title | React.ReactNode | — | Names the list, opposite the count. |
| onToggle | (id: string, done: boolean) => void | — | Called with the state the step is moving to; without it the circles are static. |
| dismissible | boolean | false | Adds a close button that removes the checklist. |
| onDismiss | () => void | — | Called after the checklist removes itself, for persisting the dismissal. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { CircleCheckIcon, CircleIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { clamp, percentOf } from "@/lib/format"
import { Button } from "@/components/ui/button"
export type OnboardingStep = {
id: string
title: React.ReactNode
description?: React.ReactNode
done: boolean
/** A button or link at the end of the row — the thing that actually finishes the step. */
action?: React.ReactNode
/** Turns the step's title into a link to where the work happens. */
href?: string
}
// `title` is content here rather than the HTML tooltip attribute, and onToggle
// takes a step id rather than a DOM toggle event, so both replace the div prop
// of the same name instead of intersecting with it.
export type OnboardingChecklistProps = Omit<
React.ComponentProps<"div">,
"title" | "onToggle"
> & {
title?: React.ReactNode
steps: OnboardingStep[]
/** Makes each circle a checkbox; without it the list is a read-only record. */
onToggle?: (id: string, done: boolean) => void
dismissible?: boolean
onDismiss?: () => void
}
function OnboardingChecklist({
className,
title,
steps,
onToggle,
dismissible = false,
onDismiss,
...props
}: OnboardingChecklistProps) {
const [dismissed, setDismissed] = React.useState(false)
// Titles are ReactNode, so they cannot name a checkbox as a string; each
// checkbox points at the title beside it instead.
const uid = React.useId()
if (dismissed) return null
const done = steps.filter((step) => step.done).length
const percent = Math.round(clamp(percentOf(done, steps.length), 0, 100))
return (
<div
data-slot="onboarding-checklist"
data-complete={(steps.length > 0 && done === steps.length) || undefined}
className={cn("flex w-full flex-col gap-3 rounded-lg border p-4", className)}
{...props}
>
<div data-slot="onboarding-checklist-header" className="flex items-baseline gap-3">
<span className="min-w-0 flex-1 truncate">
{title ? (
<span data-slot="onboarding-checklist-title" className="text-sm font-medium">
{title}
</span>
) : null}
</span>
<span
data-slot="onboarding-checklist-count"
className="shrink-0 text-xs tabular-nums text-muted-foreground"
>
{`${done} of ${steps.length}`}
</span>
{dismissible ? (
<Button
data-slot="onboarding-checklist-dismiss"
type="button"
variant="ghost"
size="icon-xs"
aria-label="Dismiss"
onClick={() => {
setDismissed(true)
onDismiss?.()
}}
className="-my-1 -me-1.5 shrink-0 self-center text-muted-foreground hover:text-foreground"
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<div
data-slot="onboarding-checklist-track"
role="progressbar"
// A ReactNode title cannot become a string, so the bar falls back to
// naming what it measures.
aria-label={typeof title === "string" ? title : "Setup progress"}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${done} of ${steps.length}`}
className="h-1 w-full overflow-hidden rounded-full bg-muted"
>
<div
data-slot="onboarding-checklist-indicator"
className={cn(
"h-full rounded-full transition-[width] duration-(--duration-slow) ease-(--ease-standard)",
percent === 100 ? "bg-success" : "bg-primary"
)}
style={{ width: `${percent}%` }}
/>
</div>
<div data-slot="onboarding-checklist-steps" className="flex flex-col gap-3">
{steps.map((step, index) => {
const titleId = `${uid}-step-${index}`
const StepIcon = step.done ? CircleCheckIcon : CircleIcon
const icon = (
<StepIcon
aria-hidden="true"
className={cn("size-5", step.done ? "text-success" : "text-muted-foreground/50")}
/>
)
return (
<div
key={step.id}
data-slot="onboarding-checklist-step"
data-done={step.done || undefined}
className="flex items-start gap-3"
>
{onToggle ? (
// A native button, so Space and Enter both toggle it without a
// key handler of its own. The negative margin grows the hit
// area to 32px while the icon stays on the title's first line.
<button
type="button"
data-slot="onboarding-checklist-check"
role="checkbox"
aria-checked={step.done}
aria-labelledby={titleId}
onClick={() => onToggle(step.id, !step.done)}
className="-m-1.5 flex shrink-0 rounded-full p-1.5 transition-colors focus-ring hover:bg-muted"
>
{icon}
</button>
) : (
<span data-slot="onboarding-checklist-check" className="flex shrink-0">
{icon}
<span className="sr-only">{step.done ? "Done" : "Not done"}</span>
</span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
id={titleId}
data-slot="onboarding-checklist-step-title"
className={cn(
"text-sm",
step.done && "text-muted-foreground line-through"
)}
>
{step.href ? (
<a
href={step.href}
className="rounded-sm focus-ring hover:underline"
>
{step.title}
</a>
) : (
step.title
)}
</span>
{step.description ? (
<span
data-slot="onboarding-checklist-description"
className="text-xs text-muted-foreground"
>
{step.description}
</span>
) : null}
</span>
{step.action ? (
<span data-slot="onboarding-checklist-action" className="shrink-0">
{step.action}
</span>
) : null}
</div>
)
})}
</div>
</div>
)
}
export { OnboardingChecklist }