Skip to contentVibraUI
Feedback & status

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-checklist

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
stepsOnboardingStep[]—One row each, in the order they should be done.
steps[].idstring—React key, and what onToggle is called with.
steps[].titleReact.ReactNode—Names the step, and names its checkbox.
steps[].descriptionReact.ReactNode—A quieter second line under the title.
steps[].doneboolean—Fills the circle, strikes the title through, and counts toward the bar.
steps[].actionReact.ReactNode—A button or link at the end of the row — the thing that actually finishes the step.
steps[].hrefstring—Turns the step's title into a link to where the work happens.
titleReact.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.
dismissiblebooleanfalseAdds a close button that removes the checklist.
onDismiss() => void—Called after the checklist removes itself, for persisting the dismissal.

Dependencies

Source

components/ui/onboarding-checklist.tsx
"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 }