{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "onboarding-checklist",
  "type": "registry:ui",
  "title": "Onboarding checklist",
  "description": "Guide a new workspace through setup, one step at a time.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/onboarding-checklist/onboarding-checklist.tsx",
      "type": "registry:component",
      "target": "@components/xzero/onboarding-checklist/onboarding-checklist.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useReducer, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { ArrowRight, ChevronUp } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./onboarding-checklist.module.css\";\n\nexport interface OnboardingStep {\n  id: string;\n  title: string;\n  description?: string;\n  /** Label of the button that does the step. Without it, the step is checked off by hand. */\n  actionLabel?: string;\n  /** Runs the step. Resolve to check it off; throw an Error to keep it open and show the error's message. */\n  onAction?: () => void | Promise<void>;\n}\n\n/**\n * A getting started checklist for a new account or workspace. The ring and count follow every check, finished steps\n * strike through and fold into a completed row that opens again, and the next step opens and takes focus.\n * Use it for a short, one time setup of three to eight steps; use a step form when the order is strict.\n */\nexport interface OnboardingChecklistProps {\n  title: string;\n  /** Title once every step is done. */\n  doneTitle?: string;\n  steps: OnboardingStep[];\n  defaultCompleted?: string[];\n  onCompletedChange?: (completed: string[]) => void;\n  defaultHidden?: boolean;\n  onHiddenChange?: (hidden: boolean) => void;\n  className?: string;\n}\n\ntype State = { done: string[]; settled: string[]; expanded: string | null; pending: string[]; errors: Record<string, string>; groupOpen: boolean; hidden: boolean; message: string };\ntype Action =\n  | { type: \"check\" | \"uncheck\" | \"settle\" | \"expand\" | \"start\"; id: string; steps: OnboardingStep[] }\n  | { type: \"fail\"; id: string; message: string }\n  | { type: \"group\" | \"hide\" };\n\n/** How long a checked step stays in place, struck through, before it folds into the completed row. */\nconst SETTLE_MS = 720;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst subscribe = () => () => {};\n/** False on the server and during hydration, so reduced motion never changes the first client render. */\nconst useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);\n\nconst ordered = (ids: string[], steps: OnboardingStep[]) => steps.map(step => step.id).filter(id => ids.includes(id));\nconst without = (ids: string[], id: string) => ids.filter(item => item !== id);\nconst omit = (errors: Record<string, string>, id: string) => Object.fromEntries(Object.entries(errors).filter(([key]) => key !== id));\nconst titleOf = (steps: OnboardingStep[], id: string) => steps.find(step => step.id === id)?.title ?? \"Step\";\n\nfunction init({ steps, completed, hidden }: { steps: OnboardingStep[]; completed: string[]; hidden: boolean }): State {\n  const done = ordered(completed, steps);\n  return { done, settled: done, expanded: steps.find(step => !done.includes(step.id))?.id ?? null, pending: [], errors: {}, groupOpen: false, hidden, message: \"\" };\n}\n\nfunction reducer(state: State, action: Action): State {\n  switch (action.type) {\n    case \"check\": {\n      if (state.done.includes(action.id)) return state;\n      const done = ordered([...state.done, action.id], action.steps);\n      const total = action.steps.length;\n      return { ...state, done, pending: without(state.pending, action.id), errors: omit(state.errors, action.id), message: done.length === total ? `All ${total} steps done.` : `${titleOf(action.steps, action.id)} done. ${done.length} of ${total} complete.` };\n    }\n    case \"uncheck\": {\n      if (!state.done.includes(action.id)) return state;\n      const done = without(state.done, action.id);\n      return { ...state, done, settled: without(state.settled, action.id), expanded: state.expanded ?? action.id, message: `${titleOf(action.steps, action.id)} marked as not done. ${done.length} of ${action.steps.length} complete.` };\n    }\n    case \"settle\": {\n      if (!state.done.includes(action.id) || state.settled.includes(action.id)) return state;\n      let expanded = state.expanded;\n      // The step that just finished hands the open panel to the next unfinished step, wrapping to the top.\n      if (expanded === action.id) {\n        const order = action.steps.map(step => step.id), start = order.indexOf(action.id);\n        expanded = order.map((_, offset) => order[(start + offset + 1) % order.length]!).find(id => !state.done.includes(id)) ?? null;\n      }\n      return { ...state, settled: ordered([...state.settled, action.id], action.steps), expanded };\n    }\n    case \"expand\": return { ...state, expanded: state.expanded === action.id ? null : action.id };\n    case \"start\": return { ...state, pending: [...without(state.pending, action.id), action.id], errors: omit(state.errors, action.id) };\n    case \"fail\": return { ...state, pending: without(state.pending, action.id), errors: { ...state.errors, [action.id]: action.message }, message: action.message };\n    case \"group\": return { ...state, groupOpen: !state.groupOpen };\n    case \"hide\": return { ...state, hidden: !state.hidden };\n  }\n}\n\n/** Rows open and close their height on a spring that never overshoots; closing is a little quicker than opening. */\nconst collapse = (reduced: boolean) => ({\n  initial: { height: 0, opacity: 0 },\n  animate: { height: \"auto\", opacity: 1, transition: reduced ? { height: { duration: 0 }, opacity: { duration: .15 } } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .04 } } },\n  exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: { ...motionTokens.spring.smooth, visualDuration: .32 }, opacity: { duration: motionTokens.duration.fast, ease: standard } } },\n});\nconst rise: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }),\n  center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\", transition: { duration: .22, ease: enter } },\n  exit: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .15, ease: standard } }),\n};\nconst fade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, transition: { duration: .15 } }, exit: { opacity: 0, transition: { duration: .1 } } };\nconst roll: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${.45 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),\n  center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\", transition: { y: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } } },\n  exit: (direction: number) => ({ opacity: 0, y: `${-.45 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .14, ease: standard } }),\n};\nconst iconSwap = (reduced: boolean) => reduced\n  ? { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: .15 } }, exit: { opacity: 0, transition: { duration: .1 } } }\n  : { initial: { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: \"blur(0px)\", transition: { scale: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } } }, exit: { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .12, ease: standard } } };\n\n/** Every label the slot can show sits invisibly underneath, so a swap never changes the slot's size. */\nfunction SwapText({ text, options, reduced }: { text: string; options: string[]; reduced: boolean }) {\n  return <span className={styles.swap} aria-hidden=\"true\">\n    {options.map(option => <span key={option} className={styles.swapSizer}>{option}</span>)}\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n      <motion.span key={text} className={styles.swapText} custom={1} variants={reduced ? fade : rise} initial=\"enter\" animate=\"center\" exit=\"exit\">{text}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\n/** Digits turn in the direction the count moved, one column per place value. */\nfunction RollingNumber({ value, reduced }: { value: number; reduced: boolean }) {\n  const [track, setTrack] = useState({ value, direction: 1 });\n  if (track.value !== value) setTrack({ value, direction: value > track.value ? 1 : -1 });\n  const digits = String(value).split(\"\");\n  return <span className={styles.number} aria-hidden=\"true\">\n    <AnimatePresence initial={false} custom={track.direction}>\n      {digits.map((digit, index) => <motion.span key={digits.length - 1 - index} className={styles.place} initial={{ width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : { width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } }}>\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={track.direction}>\n          <motion.span key={digit} className={styles.digit} custom={track.direction} variants={reduced ? fade : roll} initial=\"enter\" animate=\"center\" exit=\"exit\">{digit}</motion.span>\n        </AnimatePresence>\n      </motion.span>)}\n    </AnimatePresence>\n  </span>;\n}\n\n/** The arc fills on a spring as steps are checked; once every step is done a tick draws in its center. */\nfunction ProgressRing({ done, total, reduced }: { done: number; total: number; reduced: boolean }) {\n  const ratio = total ? done / total : 0;\n  const complete = total > 0 && done === total;\n  return <span className={styles.ring} role=\"progressbar\" aria-label=\"Setup progress\" aria-valuemin={0} aria-valuemax={total} aria-valuenow={done} aria-valuetext={`${done} of ${total} steps done`}>\n    <svg className={styles.ringArc} viewBox=\"0 0 40 40\" fill=\"none\" aria-hidden=\"true\">\n      <circle className={styles.ringTrack} cx=\"20\" cy=\"20\" r=\"17\" strokeWidth={3} />\n      <g transform=\"rotate(-90 20 20)\"><motion.circle className={styles.ringFill} cx=\"20\" cy=\"20\" r=\"17\" strokeWidth={3} strokeLinecap=\"round\" initial={false} animate={{ pathLength: ratio, opacity: ratio > 0 ? 1 : 0 }} transition={reduced ? { duration: 0 } : { pathLength: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.instant } }} /></g>\n    </svg>\n    <AnimatePresence initial={false}>\n      {complete ? <motion.svg key=\"tick\" className={styles.ringTick} viewBox=\"0 0 40 40\" fill=\"none\" aria-hidden=\"true\" {...iconSwap(reduced)}>\n        <motion.path d=\"M13.5 20.5 18 25l8.5-9.5\" stroke=\"currentColor\" strokeWidth={2.25} strokeLinecap=\"round\" strokeLinejoin=\"round\" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.standard, ease: enter, delay: .12 }} />\n      </motion.svg> : null}\n    </AnimatePresence>\n  </span>;\n}\n\n/** The fill grows from the center while the tick draws from its short stroke; unchecking retracts both quickly. */\nfunction CheckButton({ checked, labelledBy, onToggle, reduced }: { checked: boolean; labelledBy: string; onToggle: () => void; reduced: boolean }) {\n  const quick: Transition = { duration: motionTokens.duration.fast, ease: standard };\n  return <button type=\"button\" role=\"checkbox\" aria-checked={checked} aria-labelledby={labelledBy} className={styles.check} data-check=\"\" onClick={onToggle}>\n    <span className={styles.checkVisual} aria-hidden=\"true\">\n      <motion.span className={styles.checkFill} initial={false} animate={{ scale: checked ? 1 : .6, opacity: checked ? 1 : 0 }} transition={reduced ? { duration: 0 } : { scale: motionTokens.spring.snappy, opacity: { duration: checked ? motionTokens.duration.instant : motionTokens.duration.fast } }} />\n      <svg className={styles.checkMark} viewBox=\"0 0 22 22\" fill=\"none\">\n        <motion.path d=\"M6.75 11.25 9.75 14.25 15.25 8.25\" stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\" initial={false} animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }} transition={reduced ? { duration: 0 } : checked ? { pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: .05 }, opacity: { duration: .05, delay: .05 } } : { pathLength: quick, opacity: quick }} />\n      </svg>\n    </span>\n  </button>;\n}\n\nfunction ActionIcon({ state, reduced }: { state: \"idle\" | \"pending\" | \"done\"; reduced: boolean }) {\n  const icon: ReactNode = state === \"pending\" ? <span className={styles.spinner} /> : state === \"done\"\n    ? <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\"><motion.path d=\"M5 12.5l4.5 4.5L19 7.5\" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.standard, ease: enter, delay: .05 }} /></svg>\n    : <ArrowRight size={16} strokeWidth={1.75} />;\n  return <span className={styles.actionIcon} aria-hidden=\"true\">\n    <AnimatePresence initial={false}><motion.span key={state} className={styles.actionGlyph} {...iconSwap(reduced)}>{icon}</motion.span></AnimatePresence>\n  </span>;\n}\n\ninterface StepItemProps { step: OnboardingStep; baseId: string; done: boolean; expanded: boolean; pending: boolean; error?: string; reduced: boolean; onToggle: () => void; onExpand: () => void; onRun: () => void }\n\nfunction StepItem({ step, baseId, done, expanded, pending, error, reduced, onToggle, onExpand, onRun }: StepItemProps) {\n  const present = useIsPresent();\n  const titleId = `${baseId}-${step.id}-title`, panelId = `${baseId}-${step.id}-panel`;\n  const actionState = done ? \"done\" : pending ? \"pending\" : \"idle\";\n  // A leaving step is inert, so neither Tab nor the arrow keys land on it while it folds away.\n  return <motion.li className={styles.step} data-step={step.id} data-done={done || undefined} data-current={expanded || undefined} data-leaving={present ? undefined : \"\"} inert={!present} {...collapse(reduced)}>\n    <div className={styles.stepInner}>\n      <CheckButton checked={done} labelledBy={titleId} onToggle={onToggle} reduced={reduced} />\n      <div className={styles.stepMain}>\n        <button type=\"button\" className={styles.stepTitle} data-title=\"\" aria-expanded={expanded} aria-controls={panelId} onClick={onExpand}><span id={titleId} className={styles.titleText}>{step.title}</span></button>\n        <AnimatePresence initial={false}>\n          {expanded ? <motion.div key=\"panel\" id={panelId} role=\"region\" aria-labelledby={titleId} className={styles.panel} {...collapse(reduced)}>\n            <div className={styles.panelInner}>\n              {step.description ? <p className={styles.description}>{step.description}</p> : null}\n              <AnimatePresence initial={false}>\n                {error ? <motion.div key=\"error\" className={styles.errorSlot} {...collapse(reduced)}><p className={styles.error} role=\"alert\">{error}</p></motion.div> : null}\n              </AnimatePresence>\n              {step.actionLabel ? <button type=\"button\" className={styles.action} data-primary=\"\" data-state={actionState} aria-disabled={actionState !== \"idle\" || undefined} aria-busy={pending || undefined} onClick={onRun}>\n                {step.actionLabel}<ActionIcon state={actionState} reduced={reduced} />\n              </button> : null}\n            </div>\n          </motion.div> : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  </motion.li>;\n}\n\nfunction CompletedItem({ step, baseId, reduced, onToggle }: { step: OnboardingStep; baseId: string; reduced: boolean; onToggle: () => void }) {\n  const present = useIsPresent();\n  const titleId = `${baseId}-${step.id}-done`;\n  return <motion.li className={styles.step} data-completed-step={step.id} data-done=\"\" inert={!present} {...collapse(reduced)}>\n    <div className={styles.stepInner}>\n      <CheckButton checked labelledBy={titleId} onToggle={onToggle} reduced={reduced} />\n      <span className={styles.completedTitle}><span id={titleId} className={styles.titleText}>{step.title}</span></span>\n    </div>\n  </motion.li>;\n}\n\nexport function OnboardingChecklist({ title, doneTitle = \"You’re all set\", steps, defaultCompleted = [], onCompletedChange, defaultHidden = false, onHiddenChange, className }: OnboardingChecklistProps) {\n  const baseId = useId();\n  const hydrated = useHydrated();\n  const prefersReduced = useReducedMotion();\n  const reduced = hydrated && !!prefersReduced;\n  const [state, dispatch] = useReducer(reducer, { steps, completed: defaultCompleted, hidden: defaultHidden }, init);\n  const root = useRef<HTMLElement>(null);\n  const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n  const focusRequest = useRef<\"next\" | { id: string } | null>(null);\n  const reportCompleted = useRef(onCompletedChange);\n  const reported = useRef(state.done);\n\n  useEffect(() => { reportCompleted.current = onCompletedChange; });\n  useEffect(() => {\n    if (reported.current === state.done) return;\n    reported.current = state.done;\n    reportCompleted.current?.(state.done);\n  }, [state.done]);\n  useEffect(() => { const pending = timers.current; return () => pending.forEach(clearTimeout); }, []);\n  // Focus moves after the commit that mounts its target: the next step's action, or the step reopened from the completed row.\n  useEffect(() => {\n    const request = focusRequest.current, node = root.current;\n    if (!request || !node) return;\n    focusRequest.current = null;\n    let target: HTMLElement | null = null;\n    if (request === \"next\") {\n      const next = state.expanded ? node.querySelector<HTMLElement>(`[data-step=\"${state.expanded}\"]:not([data-leaving])`) : null;\n      target = next?.querySelector<HTMLElement>(\"[data-primary]\") ?? next?.querySelector<HTMLElement>(\"[data-title]\") ?? node.querySelector<HTMLElement>(\"[data-group-toggle]\");\n    } else target = node.querySelector<HTMLElement>(`[data-step=\"${request.id}\"]:not([data-leaving]) [data-check]`);\n    target?.focus({ preventScroll: true });\n  });\n\n  function check(id: string) {\n    dispatch({ type: \"check\", id, steps });\n    if (timers.current.has(id)) return;\n    timers.current.set(id, setTimeout(() => {\n      timers.current.delete(id);\n      if (root.current?.querySelector(`[data-step=\"${id}\"]`)?.contains(document.activeElement)) focusRequest.current = \"next\";\n      dispatch({ type: \"settle\", id, steps });\n    }, SETTLE_MS));\n  }\n\n  function uncheck(id: string, fromGroup: boolean) {\n    clearTimeout(timers.current.get(id));\n    timers.current.delete(id);\n    if (fromGroup) focusRequest.current = { id };\n    dispatch({ type: \"uncheck\", id, steps });\n  }\n\n  async function run(step: OnboardingStep) {\n    if (!step.onAction || state.pending.includes(step.id) || state.done.includes(step.id)) return;\n    dispatch({ type: \"start\", id: step.id, steps });\n    try {\n      await step.onAction();\n      check(step.id);\n    } catch (error) {\n      dispatch({ type: \"fail\", id: step.id, message: error instanceof Error && error.message ? error.message : \"That didn’t work. Try again.\" });\n    }\n  }\n\n  function toggleHidden() {\n    dispatch({ type: \"hide\" });\n    onHiddenChange?.(!state.hidden);\n  }\n\n  /** Up and Down walk the step titles, Home and End jump to the first and last. */\n  function navigate(event: KeyboardEvent<HTMLUListElement>) {\n    const target = event.target as HTMLElement;\n    if (!target.matches(\"[data-title]\") || ![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const titles = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(\"li:not([data-leaving]) [data-title]\"));\n    const index = titles.indexOf(target);\n    const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? titles.length - 1 : (index + (event.key === \"ArrowDown\" ? 1 : -1) + titles.length) % titles.length;\n    event.preventDefault();\n    titles[next]?.focus();\n  }\n\n  const total = steps.length, doneCount = state.done.length, allDone = total > 0 && doneCount === total;\n  const open = steps.filter(step => !state.settled.includes(step.id));\n  const completed = steps.filter(step => state.settled.includes(step.id));\n  const titleId = `${baseId}-title`, bodyId = `${baseId}-body`, groupId = `${baseId}-completed`;\n  const bodyTransition: Transition = reduced ? { duration: 0 } : state.hidden\n    ? { height: { ...motionTokens.spring.smooth, visualDuration: .34 }, opacity: { duration: motionTokens.duration.fast, ease: standard } }\n    : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .05 } };\n\n  return <section ref={root} className={[styles.card, className].filter(Boolean).join(\" \")} aria-labelledby={titleId} data-complete={allDone || undefined} data-hidden={state.hidden || undefined}>\n    <div className={styles.header}>\n      <ProgressRing done={doneCount} total={total} reduced={reduced} />\n      <div className={styles.heading}>\n        <h3 id={titleId} className={styles.title}><span className={styles.srOnly}>{allDone ? doneTitle : title}</span><SwapText text={allDone ? doneTitle : title} options={[title, doneTitle]} reduced={reduced} /></h3>\n        <p className={styles.count}><span className={styles.srOnly}>{doneCount} of {total} done</span><span className={styles.countVisual} aria-hidden=\"true\"><RollingNumber value={doneCount} reduced={reduced} />&nbsp;of {total} done</span></p>\n      </div>\n      <button type=\"button\" className={styles.hide} aria-expanded={!state.hidden} aria-controls={bodyId} onClick={toggleHidden}>\n        <span className={styles.hideLabel}><SwapText text={state.hidden ? \"Show\" : \"Hide\"} options={[\"Hide\", \"Show\"]} reduced={reduced} /></span>\n        <span className={styles.srOnly}>{state.hidden ? \"Show\" : \"Hide\"} checklist</span>\n        <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: state.hidden ? 180 : 0 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}><ChevronUp size={16} strokeWidth={1.75} /></motion.span>\n      </button>\n    </div>\n    <motion.div id={bodyId} className={styles.body} initial={false} animate={state.hidden ? { height: 0, opacity: 0 } : { height: \"auto\", opacity: 1 }} transition={bodyTransition} inert={state.hidden}>\n      <div className={styles.bodyInner}>\n        <ul className={styles.list} aria-label=\"Steps to do\" aria-hidden={open.length ? undefined : true} onKeyDown={navigate}>\n          <AnimatePresence initial={false}>\n            {open.map(step => <StepItem key={step.id} step={step} baseId={baseId} done={state.done.includes(step.id)} expanded={state.expanded === step.id} pending={state.pending.includes(step.id)} error={state.errors[step.id]} reduced={reduced}\n              onToggle={() => (state.done.includes(step.id) ? uncheck(step.id, false) : check(step.id))} onExpand={() => dispatch({ type: \"expand\", id: step.id, steps })} onRun={() => run(step)} />)}\n          </AnimatePresence>\n        </ul>\n        <AnimatePresence initial={false}>\n          {completed.length ? <motion.div key=\"completed\" className={styles.group} {...collapse(reduced)}>\n            <button type=\"button\" className={styles.groupToggle} data-group-toggle=\"\" aria-expanded={state.groupOpen} aria-controls={groupId} onClick={() => dispatch({ type: \"group\" })}>\n              <span className={styles.srOnly}>{completed.length} completed</span>\n              <span className={styles.groupLabel} aria-hidden=\"true\"><RollingNumber value={completed.length} reduced={reduced} />&nbsp;completed</span>\n              <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: state.groupOpen ? 0 : 180 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}><ChevronUp size={16} strokeWidth={1.75} /></motion.span>\n            </button>\n            <AnimatePresence initial={false}>\n              {state.groupOpen ? <motion.div key=\"list\" id={groupId} className={styles.groupPanel} {...collapse(reduced)}>\n                <ul className={styles.list} aria-label=\"Completed steps\">\n                  <AnimatePresence initial={false}>\n                    {completed.map(step => <CompletedItem key={step.id} step={step} baseId={baseId} reduced={reduced} onToggle={() => uncheck(step.id, true)} />)}\n                  </AnimatePresence>\n                </ul>\n              </motion.div> : null}\n            </AnimatePresence>\n          </motion.div> : null}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n    <span className={styles.srOnly} role=\"status\">{state.message}</span>\n  </section>;\n}\n\nexport default OnboardingChecklist;\n"
    },
    {
      "path": "registry/components/onboarding-checklist/onboarding-checklist.module.css",
      "type": "registry:component",
      "target": "@components/xzero/onboarding-checklist/onboarding-checklist.module.css",
      "content": "/* The card holds no padding of its own: the header pads itself, so hiding the body leaves an evenly padded bar. */\n.card { container-type: inline-size; width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); color: var(--foreground); }\n.header { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; column-gap: var(--space-3); padding: var(--space-5); }\n.heading { display: grid; min-width: 0; gap: 1px; }\n.title { margin: 0; color: var(--foreground); font-family: var(--font-body); font-size: var(--text-base); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.count { margin: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }\n.countVisual, .groupLabel { display: inline-flex; align-items: baseline; }\n.ring { position: relative; display: grid; width: 40px; height: 40px; color: var(--accent); }\n.ringArc, .ringTick { grid-area: 1 / 1; width: 40px; height: 40px; }\n.ringTrack { stroke: var(--border); }\n.ringFill { stroke: var(--accent); }\n/* Hide is a quiet text button; it anchors nothing, so a press only changes its color. */\n.hide { display: inline-flex; min-height: var(--control-height-sm); align-items: center; justify-content: center; gap: var(--space-1); padding: 0 var(--space-2) 0 var(--space-3); border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .hide:hover { background: var(--surface-muted); color: var(--foreground); } }\n.hide:active { background: var(--surface-muted); color: var(--foreground); }\n.chevron { display: grid; place-items: center; }\n/* Narrow cards keep the header on one line: the button keeps its spoken name and shows only the chevron. */\n@container (max-width: 380px) { .hide { width: var(--control-height-sm); padding: 0; } .hideLabel { display: none; } }\n.body { overflow: hidden; }\n.bodyInner { padding: var(--space-2); border-top: 1px solid var(--border); }\n.list { margin: 0; padding: 0; list-style: none; }\n/* Rows fold from their measured height and clip while they do. */\n.step { overflow: hidden; }\n.stepInner { display: grid; grid-template-columns: 40px minmax(0, 1fr); column-gap: var(--space-3); padding: var(--space-1) var(--space-3); border-radius: var(--radius-panel); transition: background-color var(--duration-standard) var(--ease-standard); }\n.step[data-current] .stepInner { background: var(--surface-muted); }\n@media (hover: hover) and (pointer: fine) { .step:not([data-current]) .stepInner:hover { background: color-mix(in oklab, var(--surface-muted) 60%, transparent); } }\n.check { display: grid; width: 40px; height: 40px; place-items: center; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--accent-foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; }\n/* Only the circle answers a press, so the row and its label never move. */\n.checkVisual { position: relative; display: block; width: 22px; height: 22px; border: 1.5px solid var(--border-strong); border-radius: var(--radius-pill); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .check[aria-checked=\"false\"]:hover .checkVisual { border-color: var(--text-muted); } }\n.check:active .checkVisual { transform: scale(.92); transition: border-color var(--duration-fast) var(--ease-standard), transform 110ms var(--ease-standard); }\n.check[aria-checked=\"true\"] .checkVisual { border-color: var(--accent); }\n.checkFill { position: absolute; inset: -1.5px; border-radius: inherit; background: var(--accent); }\n.checkMark { position: absolute; inset: -1.5px; width: 22px; height: 22px; }\n.stepMain { display: grid; min-width: 0; }\n.stepTitle { display: flex; width: 100%; min-height: 40px; align-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.completedTitle { display: flex; min-height: 40px; min-width: 0; align-items: center; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n/* The strike is a one pixel line of the text color that draws across, line by line when a title wraps; unchecking wipes it back quickly. */\n.titleText { background-image: linear-gradient(currentColor, currentColor); background-position: 0 60%; background-repeat: no-repeat; background-size: 0% 1px; transition: background-size var(--duration-fast) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }\n[data-done] .titleText { background-size: 100% 1px; color: var(--text-muted); transition: background-size var(--duration-considered) var(--ease-enter) 80ms, color var(--duration-standard) var(--ease-standard); }\n.panel { overflow: hidden; }\n.panelInner { padding: 0 0 var(--space-3); }\n.description { max-width: 46ch; margin: calc(var(--space-1) * -1) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n.errorSlot { overflow: hidden; }\n.error { margin: 0; padding-top: var(--space-2); color: var(--danger); font-size: var(--text-sm); line-height: var(--leading-body); }\n.action { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); margin-top: var(--space-3); padding: 0 var(--space-3) 0 var(--space-4); border: 0; border-radius: var(--radius-control); background: var(--foreground); color: var(--background); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .action[data-state=\"idle\"]:hover { box-shadow: var(--shadow-resting); } }\n.action[data-state=\"idle\"]:active { transform: scale(.97); transition: box-shadow var(--duration-fast) var(--ease-standard), transform 100ms var(--ease-standard); }\n.action[data-state=\"pending\"] { cursor: progress; }\n.action[data-state=\"done\"] { cursor: default; }\n.actionIcon { display: grid; width: 16px; height: 16px; place-items: center; }\n.actionGlyph { grid-area: 1 / 1; display: grid; width: 16px; height: 16px; place-items: center; }\n.spinner { width: 14px; height: 14px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: var(--radius-pill); animation: spin .7s linear infinite; }\n@keyframes spin { to { transform: rotate(360deg); } }\n.group { overflow: hidden; }\n.groupToggle { display: grid; width: 100%; min-height: 44px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: var(--space-3); margin-top: 2px; padding: 0 var(--space-3) 0 64px; border: 0; border-radius: var(--radius-panel); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .groupToggle:hover { background: color-mix(in oklab, var(--surface-muted) 60%, transparent); color: var(--foreground); } }\n.groupPanel { overflow: hidden; }\n.swap { position: relative; display: inline-grid; max-width: 100%; vertical-align: top; }\n.swapSizer { grid-area: 1 / 1; visibility: hidden; }\n.swapText { grid-area: 1 / 1; }\n.number { display: inline-flex; font-variant-numeric: tabular-nums; }\n/* Places clip sideways only, so a narrowing column never shows half a digit and a turning digit is never cut. */\n.place { position: relative; display: inline-block; overflow-x: clip; }\n.digit { display: inline-block; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) {\n  .hide, .stepInner, .checkVisual, .titleText, [data-done] .titleText, .action, .groupToggle { transition: none; }\n  .check:active .checkVisual, .action[data-state=\"idle\"]:active { transform: none; }\n  .spinner { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/onboarding-checklist",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "onboarding",
      "checklist",
      "getting started"
    ],
    "docs": "https://ui.xzero.co.uk/components/onboarding-checklist",
    "markdown": "https://ui.xzero.co.uk/components/onboarding-checklist/markdown"
  }
}