{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "onboarding-checklist",
  "type": "registry:ui",
  "title": "Onboarding Checklist",
  "description": "A get-started card that ticks, strikes through and opens the next task.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/onboarding-checklist.tsx",
      "content": "\"use client\";\nimport { Accordion } from \"@base-ui/react/accordion\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight, ChevronDown, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ChecklistTask = {\n  id: string;\n  title: string;\n  description?: React.ReactNode;\n  /** What does the task, shown in the open row. Usually a <ChecklistAction>. */\n  action?: React.ReactNode;\n  /** Rough minutes to finish, summed into “about N min left”. */\n  minutes?: number;\n  /** Shown with a tag and left out of the progress: the checklist completes without it. */\n  optional?: boolean;\n};\n\nexport type UseChecklistOptions = {\n  tasks: ChecklistTask[];\n  /** Ids of the finished tasks (controlled). */\n  completed?: string[];\n  defaultCompleted?: string[];\n  onCompletedChange?: (completed: string[]) => void;\n};\n\n/** Progress, the next task and the setter, without the card. */\nexport function useChecklist({ tasks, completed, defaultCompleted = [], onCompletedChange }: UseChecklistOptions) {\n  const [done, setDone] = useControllableState<string[]>({ value: completed, defaultValue: defaultCompleted, onChange: onCompletedChange });\n  const isDone = (id: string) => done.includes(id);\n  const setTask = (id: string, value: boolean) =>\n    setDone((prev) => (value ? (prev.includes(id) ? prev : [...prev, id]) : prev.filter((x) => x !== id)));\n  const required = tasks.filter((t) => !t.optional);\n  const count = required.filter((t) => done.includes(t.id)).length;\n  return {\n    completed: done,\n    isDone,\n    setTask,\n    /** Finished required tasks. Optional ones never count. */\n    count,\n    total: required.length,\n    complete: required.length > 0 && count === required.length,\n    next: tasks.find((t) => !done.includes(t.id))?.id ?? null,\n    minutesLeft: required.filter((t) => !done.includes(t.id)).reduce((sum, t) => sum + (t.minutes ?? 0), 0),\n  };\n}\n\ntype RowCtx = { id: string; title: string; done: boolean; setTask: (id: string, value: boolean) => void };\nconst Row = createContext<RowCtx | null>(null);\n\nexport type OnboardingChecklistProps = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  UseChecklistOptions & {\n    title?: React.ReactNode;\n    /** Replaces the title once every required task is done. */\n    completeTitle?: React.ReactNode;\n    /** Controls whether the card is shown. Hiding plays the exit, then onOpenChange(false). */\n    open?: boolean;\n    defaultOpen?: boolean;\n    onOpenChange?: (open: boolean) => void;\n    /** Label of the hide control, used as its accessible name. */\n    dismissLabel?: string;\n  };\n\n/**\n * The “Get started” card. The next task is open, finishing one ticks it,\n * strikes it through and moves on, and the last one closes the loop with a\n * single quiet beat of the ring.\n */\nexport function OnboardingChecklist({\n  tasks,\n  completed,\n  defaultCompleted,\n  onCompletedChange,\n  title = \"Get started\",\n  completeTitle = \"You’re all set\",\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  dismissLabel = \"Hide checklist\",\n  className,\n  ...rest\n}: OnboardingChecklistProps) {\n  const list = useChecklist({ tasks, completed, defaultCompleted, onCompletedChange });\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [expanded, setExpanded] = useState<string | null>(() => list.next);\n  const reduce = useReducedMotion();\n  const titleId = useId();\n\n  // When the open task becomes done (by its checkbox, its action or the parent),\n  // hold long enough to see the tick, then move to the next one. Opening another\n  // row in the meantime cancels the move.\n  const doneKey = list.completed.join(\"\\u0000\");\n  const prevDone = useRef(list.completed);\n  useEffect(() => {\n    const was = prevDone.current;\n    prevDone.current = list.completed;\n    if (!expanded || was.includes(expanded) || !list.completed.includes(expanded)) return;\n    const from = tasks.findIndex((t) => t.id === expanded);\n    const rest = [...tasks.slice(from + 1), ...tasks.slice(0, from)];\n    const next = rest.find((t) => !list.completed.includes(t.id))?.id ?? null;\n    const timer = window.setTimeout(() => setExpanded(next), reduce ? 200 : 460);\n    return () => window.clearTimeout(timer);\n    // doneKey stands in for the array so a parent re-render with equal contents doesn't reset the timer.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [doneKey, expanded]);\n\n  const left = list.minutesLeft;\n  // The resting target is the same with or without reduced motion, so server and client render the same style.\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" },\n  };\n\n  return (\n    <AnimatePresence initial={false}>\n      {open && (\n        <motion.section\n          key=\"checklist\"\n          aria-labelledby={titleId}\n          data-state={list.complete ? \"complete\" : \"active\"}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: 6 }}\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.97, y: 4, transition: { duration: 0.16, ease: ease.in } }}\n          transition={{ duration: 0.26, ease: ease.out }}\n          className={cn(\n            \"w-full max-w-[400px] origin-top overflow-hidden rounded-xl border border-line bg-raised text-fg shadow-[var(--shadow)]\",\n            className,\n          )}\n          {...(rest as React.ComponentProps<typeof motion.section>)}\n        >\n          <header className=\"flex items-center gap-3 py-3.5 pl-4 pr-2.5\">\n            <ProgressRing value={list.total ? list.count / list.total : 0} complete={list.complete} />\n            <div className=\"min-w-0 flex-1\">\n              <h2 id={titleId} className=\"grid text-[14px] font-medium leading-[1.3] tracking-[-0.015em]\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span key={list.complete ? \"done\" : \"active\"} className=\"col-start-1 row-start-1 truncate\" transition={{ duration: 0.24, ease: ease.out }} {...swap}>\n                    {list.complete ? completeTitle : title}\n                  </motion.span>\n                </AnimatePresence>\n              </h2>\n              <p className=\"mt-0.5 flex items-center gap-1.5 text-[12px] text-fg-3\">\n                <span className=\"tabular\">\n                  <NumberFlow value={list.count} className=\"text-fg-2\" /> of {list.total} done\n                </span>\n                {!list.complete && left > 0 && (\n                  <>\n                    <span aria-hidden className=\"text-fg-4\">·</span>\n                    <span className=\"truncate\">about {left} min left</span>\n                  </>\n                )}\n              </p>\n            </div>\n            <button\n              type=\"button\"\n              aria-label={dismissLabel}\n              onClick={() => setOpen(false)}\n              className={cn(\n                \"relative grid size-7 shrink-0 place-items-center self-start rounded-md text-fg-3 outline-none\",\n                \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              <X />\n            </button>\n          </header>\n\n          <Accordion.Root\n            value={expanded ? [expanded] : []}\n            onValueChange={(v) => setExpanded((v[0] as string | undefined) ?? null)}\n            className=\"border-t border-line\"\n          >\n            {tasks.map((task) => (\n              <TaskRow key={task.id} task={task} done={list.isDone(task.id)} setTask={list.setTask} />\n            ))}\n          </Accordion.Root>\n\n          {/* The one closing beat: a way to put the card away, opening under the list. */}\n          <div\n            data-open={list.complete ? \"\" : undefined}\n            className=\"grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-in-out-quart data-open:grid-rows-[1fr]\"\n          >\n            <div className=\"min-h-0 overflow-hidden\" inert={!list.complete}>\n              <div className=\"flex items-center justify-between gap-3 border-t border-line py-2.5 pl-4 pr-2.5\">\n                <p className=\"min-w-0 text-[12.5px] text-fg-2\">Everything’s ready to go.</p>\n                <button\n                  type=\"button\"\n                  onClick={() => setOpen(false)}\n                  className={cn(\n                    \"inline-flex h-7 shrink-0 items-center rounded-md border border-line-2 bg-raised px-2 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                    \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  {dismissLabel}\n                </button>\n              </div>\n            </div>\n          </div>\n\n          <p role=\"status\" className=\"sr-only\">\n            {list.complete ? \"All set. Every task is done.\" : `${list.count} of ${list.total} done`}\n          </p>\n        </motion.section>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction TaskRow({ task, done, setTask }: { task: ChecklistTask; done: boolean; setTask: (id: string, value: boolean) => void }) {\n  const reduce = useReducedMotion();\n  return (\n    <Row.Provider value={{ id: task.id, title: task.title, done, setTask }}>\n      <Accordion.Item value={task.id} data-done={done ? \"\" : undefined} className=\"group/task border-b border-line last:border-b-0\">\n        <Accordion.Header className=\"flex items-center pl-4 transition-colors duration-150 hover:bg-hover\">\n          <Checkbox.Root\n            checked={done}\n            onCheckedChange={(checked) => setTask(task.id, checked)}\n            aria-label={`Mark “${task.title}” as done`}\n            className={cn(\n              \"group/check relative grid size-[18px] shrink-0 place-items-center rounded-full border border-fg-4 text-frame outline-none\",\n              \"transition-[border-color,scale] duration-150 hover:border-fg-3 active:scale-[0.88] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"data-checked:border-fg\",\n              // Draws at 18px, catches a 44px finger.\n              \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:-inset-1\",\n            )}\n          >\n            {/* The fill grows from the center, then the tick draws over it. */}\n            <span\n              aria-hidden\n              className={cn(\n                \"absolute inset-[-1px] rounded-full bg-fg transition-[scale,opacity] duration-200 ease-out-expo\",\n                \"scale-50 opacity-0 group-data-checked/check:scale-100 group-data-checked/check:opacity-100\",\n                \"motion-reduce:scale-100\",\n              )}\n            />\n            <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"relative\">\n              <motion.path\n                d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                initial={false}\n                animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}\n                transition={\n                  reduce\n                    ? { duration: 0.12 }\n                    : done\n                      ? { pathLength: { duration: 0.28, ease: ease.out, delay: 0.08 }, opacity: { duration: 0.05, delay: 0.08 } }\n                      : { duration: 0.1 }\n                }\n              />\n            </svg>\n          </Checkbox.Root>\n          <Accordion.Trigger\n            className={cn(\n              \"group/trigger ml-2 flex min-h-11 min-w-0 flex-1 items-center gap-2 mr-1.5 self-stretch rounded-md py-2.5 pl-1.5 pr-2 text-left outline-none\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-fg-3\",\n            )}\n          >\n            <span className=\"relative min-w-0 flex-1\">\n              <span\n                className={cn(\n                  \"relative inline text-[13px] leading-[1.4] transition-colors duration-200\",\n                  done ? \"text-fg-3\" : \"text-fg\",\n                )}\n              >\n                {task.title}\n                {/* A strike that draws left to right, and retracts if the task is unticked. */}\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"pointer-events-none absolute left-0 right-0 top-[55%] h-px origin-left bg-current opacity-70\",\n                    \"transition-[scale] duration-300 ease-out-expo motion-reduce:transition-none\",\n                    done ? \"scale-x-100 delay-75\" : \"scale-x-0\",\n                  )}\n                />\n              </span>\n            </span>\n            {task.optional && (\n              <span className=\"shrink-0 rounded-full border border-line-2 px-1.5 py-px text-[10.5px] leading-[14px] text-fg-3\">Optional</span>\n            )}\n            {task.minutes != null && !done && (\n              <span className=\"tabular shrink-0 text-[11.5px] text-fg-4 max-[360px]:hidden\">{task.minutes} min</span>\n            )}\n            <ChevronDown\n              className=\"shrink-0 text-fg-3 transition-transform duration-200 ease-out-expo group-data-[panel-open]/trigger:rotate-180 motion-reduce:transition-none\"\n            />\n          </Accordion.Trigger>\n        </Accordion.Header>\n        <Accordion.Panel\n          className={cn(\n            \"h-[var(--accordion-panel-height)] overflow-hidden\",\n            \"transition-[height,opacity] duration-[260ms] ease-in-out-quart\",\n            \"data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0 data-ending-style:duration-200\",\n          )}\n        >\n          <div className=\"pb-3.5 pl-12 pr-4\">\n            {task.description && <p className=\"text-[12.5px] leading-[1.5] text-fg-2 text-pretty\">{task.description}</p>}\n            <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n              {!done && task.action}\n              <button\n                type=\"button\"\n                onClick={() => setTask(task.id, !done)}\n                className={cn(\n                  \"inline-flex h-7 items-center rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none\",\n                  \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                )}\n              >\n                {done ? \"Mark as not done\" : task.optional ? \"Skip\" : \"Mark as done\"}\n              </button>\n            </div>\n          </div>\n        </Accordion.Panel>\n      </Accordion.Item>\n    </Row.Provider>\n  );\n}\n\nexport type ChecklistActionProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  children: string;\n  /** Renders a link instead of a button, for tasks that live on another page. */\n  href?: string;\n  /** Runs on press. Return a promise to show the busy state; when it resolves the task is marked done. */\n  onAction?: () => void | Promise<unknown>;\n  /** Shown while the promise runs, e.g. “Connecting…”. */\n  pendingLabel?: string;\n  /** Shown under the actions when the promise rejects. */\n  errorMessage?: string;\n  /** Mark the task done when onAction resolves. */\n  completes?: boolean;\n};\n\n/** The primary action of a task. Knows which task it belongs to, and ticks it off when its work resolves. */\nexport function ChecklistAction({\n  children,\n  href,\n  onAction,\n  pendingLabel,\n  errorMessage = \"That didn’t go through. Try again.\",\n  completes = true,\n  className,\n  disabled,\n  ...rest\n}: ChecklistActionProps) {\n  const row = useContext(Row);\n  const reduce = useReducedMotion();\n  const [state, setState] = useState<\"idle\" | \"busy\" | \"failed\">(\"idle\");\n  const busy = state === \"busy\";\n  const busyLabel = pendingLabel ?? `${children}…`;\n  const style = cn(\n    \"relative inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame shadow-[var(--shadow)] outline-none\",\n    \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    \"disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-busy:active:scale-100\",\n    className,\n  );\n\n  if (href)\n    return (\n      <a href={href} className={style}>\n        {children}\n        <ArrowUpRight className=\"size-3.5 opacity-70\" />\n      </a>\n    );\n\n  const run = async () => {\n    if (busy) return;\n    const result = onAction?.();\n    if (!(result instanceof Promise)) {\n      if (completes && row) row.setTask(row.id, true);\n      return;\n    }\n    setState(\"busy\");\n    try {\n      await result;\n      setState(\"idle\");\n      if (completes && row) row.setTask(row.id, true);\n    } catch {\n      setState(\"failed\");\n    }\n  };\n\n  return (\n    <>\n      <button type=\"button\" aria-busy={busy || undefined} disabled={disabled} onClick={run} className={style} {...rest}>\n        {/* Both labels share one cell, so the button holds the width of the longer. */}\n        <span className=\"grid\">\n          <span aria-hidden className=\"invisible col-start-1 row-start-1\">{children}</span>\n          {onAction && <span aria-hidden className=\"invisible col-start-1 row-start-1 pl-5\">{busyLabel}</span>}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={busy ? \"busy\" : \"idle\"}\n              className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n              transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n            >\n              {busy && <Spinner />}\n              {busy ? busyLabel : children}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n      {state === \"failed\" && (\n        <p role=\"alert\" className=\"basis-full text-[12px] leading-[1.45] text-danger\">\n          {errorMessage}\n        </p>\n      )}\n    </>\n  );\n}\n\n// Progress as an arc that springs forward. At 100% the arc closes into a\n// filled disc and the tick draws: the one moment the card allows itself.\nfunction ProgressRing({ value, complete }: { value: number; complete: boolean }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      aria-hidden\n      className=\"relative grid size-8 shrink-0 place-items-center\"\n      animate={{ scale: complete ? [1, 1.08, 1] : 1 }}\n      transition={reduce ? { duration: 0 } : { duration: 0.42, ease: ease.inOut, delay: 0.3 }}\n    >\n      <svg width=\"32\" height=\"32\" viewBox=\"0 0 32 32\" fill=\"none\" className=\"-rotate-90\">\n        <circle cx=\"16\" cy=\"16\" r=\"13\" stroke=\"var(--line-2)\" strokeWidth=\"2.5\" />\n        <motion.circle\n          cx=\"16\"\n          cy=\"16\"\n          r=\"13\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={{ pathLength: Math.max(value, 0.001), opacity: value > 0 ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : spring.soft}\n        />\n      </svg>\n      <AnimatePresence initial={false}>\n        {complete && (\n          <motion.span\n            key=\"disc\"\n            className=\"absolute inset-0 grid place-items-center rounded-full bg-fg text-frame\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.15 } : { ...spring.pop, delay: 0.22 }}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <motion.path\n                d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                initial={{ pathLength: reduce ? 1 : 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 0.32, ease: ease.out, delay: 0.36 }}\n              />\n            </svg>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"shrink-0 animate-[spin_0.7s_linear_infinite]\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.6\" />\n      <path d=\"M8 2.25A5.75 5.75 0 0 1 13.75 8\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/onboarding-checklist"
}
