Stepper

Setup Progress

Onboarding checklist card with a read-only segmented bar counting done tasks, struck-through completed items, inline actions for open ones, a revealable CLI command and a Hide control.

Free, MIT licenseOpen in Browse
"use client"

import { useState } from "react"
import { CheckIcon } from "@phosphor-icons/react"

import {
  Stepper,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Finish setting up",
  done: "done",
  of: "of",
  dismiss: "Hide",
  show: "Show setup",
  command: "pnpm dlx shadcn@latest add @01orbital/setup-progress",
  tasks: [
    { title: "Create the workspace", done: true },
    { title: "Add a payment method", done: true },
    { title: "Invite a teammate", done: true },
    {
      title: "Connect a repository",
      done: false,
      action: "Connect",
      kind: "connect",
    },
    {
      title: "Install the CLI",
      done: false,
      action: "Show command",
      kind: "command",
    },
  ],
} as const

// Read-only bars under a card title: a count of done tasks, not a workflow
// stage, so current={false} keeps aria-current off. Bars fill through
// value, so done - 1 is the last done task and none done leaves the strip
// empty. The open tasks are listed with their action.
export default function SetupProgress() {
  // Local state for the demo: Hide collapses the card, Connect completes its
  // task, Show command reveals the command. A real block reads these from the
  // account.
  const [hidden, setHidden] = useState(false)
  const [connected, setConnected] = useState(false)
  const [showCommand, setShowCommand] = useState(false)
  const tasks = COPY.tasks.map((t) =>
    "kind" in t && t.kind === "connect" ? { ...t, done: connected } : t
  )
  const done = tasks.filter((t) => t.done).length
  if (hidden)
    return (
      <div className="mx-auto flex w-full max-w-sm justify-end">
        <Button variant="ghost" size="sm" onClick={() => setHidden(false)}>
          {COPY.show}
        </Button>
      </div>
    )
  return (
    <Card className="mx-auto w-full max-w-sm">
      <CardHeader>
        <CardTitle>{COPY.title}</CardTitle>
        <CardDescription>
          {done} {COPY.of} {COPY.tasks.length} {COPY.done}
        </CardDescription>
        <CardAction>
          <Button variant="ghost" size="sm" onClick={() => setHidden(true)}>
            {COPY.dismiss}
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent className="grid gap-5">
        <Stepper
          value={done - 1}
          variant="bars"
          rail="ink"
          current={false}
          className="gap-0"
        >
          <StepperList>
            {tasks.map((t, i) => (
              <StepperItem key={t.title} value={i}>
                <StepperSeparator />
                <StepperTitle className="sr-only">{t.title}</StepperTitle>
              </StepperItem>
            ))}
          </StepperList>
        </Stepper>
        <ul className="grid gap-2.5 text-sm">
          {tasks.map((t) => (
            <li key={t.title} className="flex flex-wrap items-center gap-3">
              <span
                className={
                  t.done
                    ? "flex size-4 items-center justify-center rounded-full bg-foreground text-background"
                    : "size-4 rounded-full ring-1 ring-border ring-inset"
                }
              >
                {t.done && (
                  <CheckIcon className="size-2.5" weight="bold" aria-hidden />
                )}
              </span>
              <span
                className={
                  t.done ? "text-muted-foreground line-through" : "flex-1"
                }
              >
                {t.title}
              </span>
              {"action" in t && !t.done && (
                <Button
                  size="xs"
                  variant="outline"
                  className="ml-auto"
                  aria-expanded={t.kind === "command" ? showCommand : undefined}
                  onClick={() =>
                    t.kind === "connect"
                      ? setConnected(true)
                      : setShowCommand((v) => !v)
                  }
                >
                  {t.action}
                </Button>
              )}
              {"kind" in t && t.kind === "command" && showCommand && (
                <pre className="basis-full overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs leading-6">
                  {COPY.command}
                </pre>
              )}
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  )
}

Installation

pnpm dlx shadcn add @01orbital/setup-progress
npx shadcn add @01orbital/setup-progress
yarn shadcn add @01orbital/setup-progress
bunx --bun shadcn add @01orbital/setup-progress

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/stepper/setup-progress/index.tsx, as source you own and edit.

Usage

import SetupProgress from "@/components/blocks/stepper/setup-progress"

export default function Example() {
  return <SetupProgress />
}

Dependencies

npm: @phosphor-icons/react. The CLI installs them for you.

Registry pieces it installs with it: