{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"onboarding-checklist","type":"registry:component","title":"Onboarding checklist","description":"The first five things, ticked off in place: a ring that fills, checks that draw, and a finish worth seeing.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/onboarding-checklist.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * ONBOARDING CHECKLIST: the first five things, ticked off\n *\n *   next       the first open task is open in place: what it is,\n *              why it matters and the one button that does it\n *   doing      its button spins while your action runs, then a\n *              check draws itself on the row, a strike draws\n *              through the label and the next task opens\n *   progress   a ring fills and \"2 of 5\" rolls with every tick\n *   pick       open any task by its row, done or not\n *   folded     the whole card folds to its header (ring and\n *              count) and opens again\n *   done       the list folds away, the ring fills and becomes a\n *              check with a small burst, and the title morphs to\n *              \"You're all set\"\n *\n * Drive it from your data (done per task) or let it keep its own.\n * ───────────────────────────────────────────────────────── */\n\nexport interface OnboardingTask {\n  id: string;\n  title: string;\n  description?: string;\n  /** The one button that does the task. Return a promise to show progress; it's ticked off when it resolves. */\n  action?: { label: string; /** Shown while a promise runs, e.g. \"Connecting\". */ pendingLabel?: string; onClick?: () => void | Promise<unknown>; href?: string };\n  /** Controlled: whether it's done. Leave out to let the checklist track it. */\n  done?: boolean;\n}\n\nexport interface OnboardingChecklistProps extends React.HTMLAttributes<HTMLDivElement> {\n  tasks: OnboardingTask[];\n  title?: string;\n  doneTitle?: string;\n  doneDescription?: string;\n  /** Called when a task is ticked off (its action resolved). */\n  onTaskComplete?: (id: string) => void;\n  /** Called with \"Hide\" once everything is done. */\n  onDismiss?: () => void;\n  defaultCollapsed?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}`,\n});\n\nfunction Check({ drawn, reduced, width = 2, delay = 80 }: { drawn: boolean; reduced: boolean; width?: number; delay?: number }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-full\">\n      <path\n        d=\"M4.2 8.4 6.8 11 11.8 5.4\"\n        stroke=\"currentColor\"\n        strokeWidth={width}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 380ms ${EASE} ${delay}ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\n/** The ring that fills with progress, and becomes a check with a small burst when it's full. */\nfunction Ring({ value, complete, reduced }: { value: number; complete: boolean; reduced: boolean }) {\n  const c = 2 * Math.PI * 9;\n  const burstRef = React.useRef<HTMLSpanElement>(null);\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (!complete || reduced) return;\n    const dots = burstRef.current?.children;\n    if (!dots) return;\n    Array.from(dots).forEach((dot, i) => {\n      const angle = (i / dots.length) * Math.PI * 2;\n      const dist = 20 + (i % 2) * 6;\n      (dot as HTMLElement).animate(\n        [\n          { transform: \"translate(0,0) scale(1)\", opacity: 0 },\n          { opacity: 1, offset: 0.2 },\n          { transform: `translate(${Math.cos(angle) * dist}px, ${Math.sin(angle) * dist}px) scale(0.4)`, opacity: 0 },\n        ],\n        { duration: 700, easing: EASE, delay: 260 }\n      );\n    });\n  }, [complete, reduced]);\n  return (\n    <span aria-hidden=\"true\" className=\"relative flex size-6 shrink-0 items-center justify-center\">\n      <svg viewBox=\"0 0 24 24\" className=\"absolute inset-0 size-full -rotate-90\">\n        <circle cx=\"12\" cy=\"12\" r=\"9\" fill=\"none\" strokeWidth=\"2\" className=\"stroke-border\" />\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9\"\n          fill=\"none\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeDasharray={c}\n          className=\"stroke-primary\"\n          style={{ strokeDashoffset: c * (1 - value), transition: reduced ? \"none\" : `stroke-dashoffset 640ms ${EASE}` }}\n        />\n      </svg>\n      <span\n        className=\"absolute inset-0 rounded-full bg-primary text-primary-foreground\"\n        style={{ transform: complete ? \"none\" : \"scale(0.4)\", opacity: complete ? 1 : 0, transition: reduced ? \"none\" : `transform 460ms ${THROW} 180ms, opacity 260ms ${EASE} 180ms` }}\n      >\n        <span className=\"absolute inset-[5px]\">\n          <Check drawn={complete} reduced={reduced} width={2.4} delay={360} />\n        </span>\n      </span>\n      <span ref={burstRef} className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n        {Array.from({ length: 10 }, (_, i) => (\n          <span key={i} className=\"absolute size-1 rounded-full bg-primary opacity-0\" />\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport function OnboardingChecklist({\n  tasks,\n  title = \"Get started\",\n  doneTitle = \"You’re all set\",\n  doneDescription = \"Everything’s in place. Here’s to the first busy Saturday.\",\n  onTaskComplete,\n  onDismiss,\n  defaultCollapsed = false,\n  className = \"\",\n  ...props\n}: OnboardingChecklistProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState<Set<string>>(() => new Set());\n  const [running, setRunning] = React.useState<string | null>(null);\n  const [collapsed, setCollapsed] = React.useState(defaultCollapsed);\n  const isDone = (t: OnboardingTask) => t.done ?? own.has(t.id);\n  const firstOpen = tasks.find((t) => !isDone(t))?.id ?? null;\n  const [picked, setPicked] = React.useState<string | null>(null);\n  const openId = picked ?? firstOpen;\n  const count = tasks.filter(isDone).length;\n  const complete = count === tasks.length && tasks.length > 0;\n\n  // When the open task gets done, the next open one takes over (unless someone picked a row since).\n  const lastFirst = React.useRef(firstOpen);\n  React.useEffect(() => {\n    if (lastFirst.current !== firstOpen) {\n      lastFirst.current = firstOpen;\n      setPicked(null);\n    }\n  }, [firstOpen]);\n\n  const run = async (task: OnboardingTask) => {\n    if (!task.action || running) return;\n    const result = task.action.onClick?.();\n    if (task.action.href && !task.action.onClick) {\n      window.location.assign(task.action.href);\n      return;\n    }\n    if (result instanceof Promise) {\n      setRunning(task.id);\n      try {\n        await result;\n      } catch {\n        setRunning(null);\n        return;\n      }\n      setRunning(null);\n    }\n    if (task.done === undefined) setOwn((s) => new Set(s).add(task.id));\n    onTaskComplete?.(task.id);\n  };\n\n  return (\n    <section aria-labelledby={`${id}-title`} className={`w-full overflow-hidden rounded-[20px] border border-border bg-background ${className}`} {...props}>\n      <div className=\"flex items-center gap-3 px-4 py-3.5\">\n        <Ring value={tasks.length ? count / tasks.length : 0} complete={complete} reduced={reduced} />\n        <div className=\"min-w-0 flex-1\">\n          <h3 id={`${id}-title`} className=\"text-[14px] font-medium leading-5 text-foreground\">\n            <TextMorph>{complete ? doneTitle : title}</TextMorph>\n          </h3>\n          <p className=\"flex items-baseline gap-[0.3em] text-[12px] leading-4 text-muted-foreground tabular-nums\">\n            <NumberRoll value={count} duration={700} /> of {tasks.length} done\n          </p>\n        </div>\n        <span role=\"status\" className=\"sr-only\">\n          {complete ? doneTitle : `${count} of ${tasks.length} done`}\n        </span>\n        {complete && onDismiss ? (\n          <button\n            type=\"button\"\n            onClick={onDismiss}\n            className={`h-8 rounded-full px-3 text-[12.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent animate-[ui-fade-in_300ms_ease-out_400ms_both] motion-reduce:animate-none ${FOCUS}`}\n          >\n            Hide\n          </button>\n        ) : (\n          <button\n            type=\"button\"\n            aria-expanded={!collapsed}\n            aria-controls={`${id}-list`}\n            aria-label={collapsed ? \"Show checklist\" : \"Fold checklist\"}\n            onClick={() => setCollapsed((c) => !c)}\n            className={`flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n          >\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 12 12\"\n              className=\"size-3 transition-transform duration-300 motion-reduce:transition-none\"\n              style={{ transform: collapsed ? \"none\" : \"rotate(180deg)\", transitionTimingFunction: EASE }}\n            >\n              <path d=\"m3 4.5 3 3 3-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Done: the list folds away and a line says so. */}\n      <div\n        aria-hidden={!complete || undefined}\n        className=\"grid\"\n        style={{ gridTemplateRows: complete ? \"1fr\" : \"0fr\", opacity: complete ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE} 200ms, opacity 360ms ${EASE} 320ms` }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <p className=\"px-4 pb-4 text-[13px] leading-relaxed text-muted-foreground\">{doneDescription}</p>\n        </div>\n      </div>\n\n      <div\n        id={`${id}-list`}\n        inert={collapsed || complete}\n        className=\"grid\"\n        style={{\n          gridTemplateRows: collapsed || complete ? \"0fr\" : \"1fr\",\n          opacity: collapsed || complete ? 0 : 1,\n          transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}, opacity ${collapsed || complete ? \"180ms\" : \"360ms\"} ${EASE}`,\n        }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <ol className=\"border-t border-border p-1.5\">\n            {tasks.map((task, i) => {\n              const done = isDone(task);\n              const open = task.id === openId;\n              const busy = running === task.id;\n              return (\n                <li key={task.id} className={`rounded-[14px] transition-colors duration-300 ${open ? \"bg-accent/60\" : \"\"}`}>\n                  <button\n                    type=\"button\"\n                    aria-expanded={open}\n                    aria-controls={`${id}-t${i}`}\n                    onClick={() => setPicked(open ? (task.id === firstOpen ? \"__none__\" : null) : task.id)}\n                    className={`flex w-full items-center gap-3 rounded-[14px] px-2.5 py-2.5 text-left ${FOCUS}`}\n                  >\n                    {/* An open ring that becomes a filled, drawn check. */}\n                    <span aria-hidden=\"true\" className=\"relative flex size-5 shrink-0 items-center justify-center\">\n                      <span className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1.5px_var(--border)]\" style={swap(!done, reduced)} />\n                      <span className=\"absolute inset-0 rounded-full bg-foreground text-background\" style={swap(done, reduced)}>\n                        <Check drawn={done} reduced={reduced} />\n                      </span>\n                    </span>\n                    <span className=\"min-w-0 flex-1 text-[13.5px]\">\n                      {/* The strike draws itself through the label once it's done. */}\n                      <span\n                        className={`bg-[linear-gradient(currentColor,currentColor)] bg-no-repeat [background-position:0_58%] transition-colors duration-300 ${done ? \"text-muted-foreground\" : \"text-foreground\"}`}\n                        style={{ backgroundSize: done ? \"100% 1px\" : \"0% 1px\", transition: reduced ? \"none\" : `background-size 480ms ${EASE} 120ms, color 300ms` }}\n                      >\n                        {task.title}\n                      </span>\n                      <span className=\"sr-only\">{done ? \", done\" : \"\"}</span>\n                    </span>\n                  </button>\n                  <div\n                    id={`${id}-t${i}`}\n                    inert={!open}\n                    className=\"grid\"\n                    style={{\n                      gridTemplateRows: open ? \"1fr\" : \"0fr\",\n                      opacity: open ? 1 : 0,\n                      transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}, opacity ${open ? \"320ms\" : \"140ms\"} ${EASE}`,\n                    }}\n                  >\n                    <div className=\"min-h-0 overflow-hidden\">\n                      <div className=\"pb-3 pl-[42px] pr-3\">\n                        {task.description && <p className=\"text-[12.5px] leading-relaxed text-muted-foreground\">{task.description}</p>}\n                        {task.action && !done && (\n                          <button\n                            type=\"button\"\n                            onClick={() => run(task)}\n                            aria-busy={busy || undefined}\n                            className={`mt-3 inline-flex h-8 items-center rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-[background-color,transform] duration-200 hover:bg-primary/90 active:scale-[0.97] ${FOCUS}`}\n                          >\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"flex shrink-0 items-center justify-center overflow-hidden\"\n                              style={{ width: busy ? 18 : 0, transition: reduced ? \"none\" : `width 320ms ${EASE}` }}\n                            >\n                              <span className={`size-3 rounded-full border-[1.5px] border-primary-foreground/40 border-t-primary-foreground motion-reduce:animate-none ${busy ? \"animate-spin\" : \"\"}`} />\n                            </span>\n                            <TextMorph>{busy ? (task.action.pendingLabel ?? \"Working on it\") : task.action.label}</TextMorph>\n                          </button>\n                        )}\n                      </div>\n                    </div>\n                  </div>\n                </li>\n              );\n            })}\n          </ol>\n        </div>\n      </div>\n    </section>\n  );\n}\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/onboarding-checklist"}