{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\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 = {\n  done: string[];\n  settled: string[];\n  expanded: string | null;\n  pending: string[];\n  errors: Record<string, string>;\n  groupOpen: boolean;\n  hidden: boolean;\n  message: string;\n};\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 = () =>\n  useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n\nconst ordered = (ids: string[], steps: OnboardingStep[]) =>\n  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) =>\n  Object.fromEntries(Object.entries(errors).filter(([key]) => key !== id));\nconst titleOf = (steps: OnboardingStep[], id: string) => steps.find((step) => step.id === id)?.title ?? \"Step\";\nconst srOnly = \"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\nconst stepRow =\n  \"grid grid-cols-[40px_minmax(0,1fr)] gap-x-3 rounded-panel px-3 py-1 transition-[background-color] duration-standard ease-standard motion-reduce:transition-none\";\nconst softHover = \"hover:bg-[color-mix(in_oklab,var(--hover)_60%,transparent)]\";\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. */\nconst titleText = (done: boolean) =>\n  cn(\n    \"bg-[linear-gradient(currentColor,currentColor)] bg-[position:0_60%] bg-no-repeat transition-[background-size,color] motion-reduce:transition-none\",\n    done\n      ? \"bg-[size:100%_1px] text-ink-3 delay-[80ms,0s] duration-[var(--duration-considered),var(--duration-standard)] ease-[var(--ease-enter),var(--ease-standard)]\"\n      : \"bg-[size:0%_1px] duration-[var(--duration-fast),var(--duration-standard)] ease-standard\",\n  );\n\nfunction init({ steps, completed, hidden }: { steps: OnboardingStep[]; completed: string[]; hidden: boolean }): State {\n  const done = ordered(completed, steps);\n  return {\n    done,\n    settled: done,\n    expanded: steps.find((step) => !done.includes(step.id))?.id ?? null,\n    pending: [],\n    errors: {},\n    groupOpen: false,\n    hidden,\n    message: \"\",\n  };\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 {\n        ...state,\n        done,\n        pending: without(state.pending, action.id),\n        errors: omit(state.errors, action.id),\n        message:\n          done.length === total\n            ? `All ${total} steps done.`\n            : `${titleOf(action.steps, action.id)} done. ${done.length} of ${total} complete.`,\n      };\n    }\n    case \"uncheck\": {\n      if (!state.done.includes(action.id)) return state;\n      const done = without(state.done, action.id);\n      return {\n        ...state,\n        done,\n        settled: without(state.settled, action.id),\n        expanded: state.expanded ?? action.id,\n        message: `${titleOf(action.steps, action.id)} marked as not done. ${done.length} of ${action.steps.length} complete.`,\n      };\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),\n          start = order.indexOf(action.id);\n        expanded =\n          order\n            .map((_, offset) => order[(start + offset + 1) % order.length]!)\n            .find((id) => !state.done.includes(id)) ?? null;\n      }\n      return { ...state, settled: ordered([...state.settled, action.id], action.steps), expanded };\n    }\n    case \"expand\":\n      return { ...state, expanded: state.expanded === action.id ? null : action.id };\n    case \"start\":\n      return {\n        ...state,\n        pending: [...without(state.pending, action.id), action.id],\n        errors: omit(state.errors, action.id),\n      };\n    case \"fail\":\n      return {\n        ...state,\n        pending: without(state.pending, action.id),\n        errors: { ...state.errors, [action.id]: action.message },\n        message: action.message,\n      };\n    case \"group\":\n      return { ...state, groupOpen: !state.groupOpen };\n    case \"hide\":\n      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: {\n    height: \"auto\",\n    opacity: 1,\n    transition: reduced\n      ? { height: { duration: 0 }, opacity: { duration: 0.15 } }\n      : {\n          height: motionTokens.spring.smooth,\n          opacity: { duration: motionTokens.duration.standard, ease: enter, delay: 0.04 },\n        },\n  },\n  exit: {\n    height: 0,\n    opacity: 0,\n    transition: reduced\n      ? { duration: 0 }\n      : {\n          height: { ...motionTokens.spring.smooth, visualDuration: 0.32 },\n          opacity: { duration: motionTokens.duration.fast, ease: standard },\n        },\n  },\n});\nconst rise: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\", transition: { duration: 0.22, ease: enter } },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: 0.15, ease: standard },\n  }),\n};\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\nconst roll: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${0.45 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: {\n    opacity: 1,\n    y: \"0em\",\n    filter: \"blur(0px)\",\n    transition: {\n      y: motionTokens.spring.snappy,\n      opacity: { duration: motionTokens.duration.fast },\n      filter: { duration: motionTokens.duration.fast },\n    },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.45 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: 0.14, ease: standard },\n  }),\n};\nconst iconSwap = (reduced: boolean) =>\n  reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1, transition: { duration: 0.15 } },\n        exit: { opacity: 0, transition: { duration: 0.1 } },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` },\n        animate: {\n          opacity: 1,\n          scale: 1,\n          filter: \"blur(0px)\",\n          transition: {\n            scale: motionTokens.spring.snappy,\n            opacity: { duration: motionTokens.duration.fast, ease: enter },\n            filter: { duration: motionTokens.duration.fast, ease: enter },\n          },\n        },\n        exit: {\n          opacity: 0,\n          scale: 0.6,\n          filter: `blur(${motionTokens.blur.subtle}px)`,\n          transition: { duration: 0.12, ease: standard },\n        },\n      };\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 (\n    <span className=\"relative inline-grid max-w-full align-top\" aria-hidden=\"true\">\n      {options.map((option) => (\n        <span key={option} className=\"invisible [grid-area:1/1]\">\n          {option}\n        </span>\n      ))}\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n        <motion.span\n          key={text}\n          className=\"[grid-area:1/1]\"\n          custom={1}\n          variants={reduced ? fade : rise}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\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 (\n    <span className=\"inline-flex tabular-nums\" aria-hidden=\"true\">\n      <AnimatePresence initial={false} custom={track.direction}>\n        {digits.map((digit, index) => (\n          <motion.span\n            key={digits.length - 1 - index}\n            className=\"relative inline-block overflow-x-clip\"\n            initial={{ width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={{ width: 0, opacity: 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } }\n            }\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={track.direction}>\n              <motion.span\n                key={digit}\n                className=\"inline-block\"\n                custom={track.direction}\n                variants={reduced ? fade : roll}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n              >\n                {digit}\n              </motion.span>\n            </AnimatePresence>\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\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 (\n    <span\n      className=\"relative grid size-10 text-accent\"\n      role=\"progressbar\"\n      aria-label=\"Setup progress\"\n      aria-valuemin={0}\n      aria-valuemax={total}\n      aria-valuenow={done}\n      aria-valuetext={`${done} of ${total} steps done`}\n    >\n      <svg className=\"size-10 [grid-area:1/1]\" viewBox=\"0 0 40 40\" fill=\"none\" aria-hidden=\"true\">\n        <circle className=\"stroke-line\" cx=\"20\" cy=\"20\" r=\"17\" strokeWidth={3} />\n        <g transform=\"rotate(-90 20 20)\">\n          <motion.circle\n            className=\"stroke-accent\"\n            cx=\"20\"\n            cy=\"20\"\n            r=\"17\"\n            strokeWidth={3}\n            strokeLinecap=\"round\"\n            initial={false}\n            animate={{ pathLength: ratio, opacity: ratio > 0 ? 1 : 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { pathLength: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.instant } }\n            }\n          />\n        </g>\n      </svg>\n      <AnimatePresence initial={false}>\n        {complete ? (\n          <motion.svg\n            key=\"tick\"\n            className=\"size-10 [grid-area:1/1]\"\n            viewBox=\"0 0 40 40\"\n            fill=\"none\"\n            aria-hidden=\"true\"\n            {...iconSwap(reduced)}\n          >\n            <motion.path\n              d=\"M13.5 20.5 18 25l8.5-9.5\"\n              stroke=\"currentColor\"\n              strokeWidth={2.25}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduced ? false : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: motionTokens.duration.standard, ease: enter, delay: 0.12 }}\n            />\n          </motion.svg>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** The fill grows from the center while the tick draws from its short stroke; unchecking retracts both quickly. */\nfunction CheckButton({\n  checked,\n  labelledBy,\n  onToggle,\n  reduced,\n}: {\n  checked: boolean;\n  labelledBy: string;\n  onToggle: () => void;\n  reduced: boolean;\n}) {\n  const quick: Transition = { duration: motionTokens.duration.fast, ease: standard };\n  return (\n    <button\n      type=\"button\"\n      role=\"checkbox\"\n      aria-checked={checked}\n      aria-labelledby={labelledBy}\n      className=\"group/check grid size-10 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-on-accent [-webkit-tap-highlight-color:transparent]\"\n      data-check=\"\"\n      onClick={onToggle}\n    >\n      <span\n        className={cn(\n          \"relative block size-[22px] rounded-full border-[1.5px] bg-surface transition-[border-color,scale] duration-[var(--duration-fast),var(--duration-spring)] ease-[var(--ease-standard),var(--ease-spring)] group-active/check:scale-92 group-active/check:duration-[var(--duration-fast),110ms] group-active/check:ease-standard motion-reduce:transition-none motion-reduce:group-active/check:scale-100\",\n          checked ? \"border-accent\" : \"border-line-strong group-hover/check:border-ink-3\",\n        )}\n        aria-hidden=\"true\"\n      >\n        <motion.span\n          className=\"absolute -inset-[1.5px] rounded-[inherit] bg-accent\"\n          initial={false}\n          animate={{ scale: checked ? 1 : 0.6, opacity: checked ? 1 : 0 }}\n          transition={\n            reduced\n              ? { duration: 0 }\n              : {\n                  scale: motionTokens.spring.snappy,\n                  opacity: { duration: checked ? motionTokens.duration.instant : motionTokens.duration.fast },\n                }\n          }\n        />\n        <svg className=\"absolute -inset-[1.5px] size-[22px]\" viewBox=\"0 0 22 22\" fill=\"none\">\n          <motion.path\n            d=\"M6.75 11.25 9.75 14.25 15.25 8.25\"\n            stroke=\"currentColor\"\n            strokeWidth={1.75}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={false}\n            animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : checked\n                  ? {\n                      pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: 0.05 },\n                      opacity: { duration: 0.05, delay: 0.05 },\n                    }\n                  : { pathLength: quick, opacity: quick }\n            }\n          />\n        </svg>\n      </span>\n    </button>\n  );\n}\n\nfunction ActionIcon({ state, reduced }: { state: \"idle\" | \"pending\" | \"done\"; reduced: boolean }) {\n  const icon: ReactNode =\n    state === \"pending\" ? (\n      <span className=\"size-3.5 animate-onboarding-checklist-spin rounded-full border-[1.5px] border-current border-r-transparent motion-reduce:animate-none\" />\n    ) : state === \"done\" ? (\n      <svg\n        width={16}\n        height={16}\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        <motion.path\n          d=\"M5 12.5l4.5 4.5L19 7.5\"\n          initial={reduced ? false : { pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: motionTokens.duration.standard, ease: enter, delay: 0.05 }}\n        />\n      </svg>\n    ) : (\n      <ArrowRight size={16} strokeWidth={1.75} />\n    );\n  return (\n    <span className=\"grid size-4 place-items-center\" aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        <motion.span key={state} className=\"grid size-4 place-items-center [grid-area:1/1]\" {...iconSwap(reduced)}>\n          {icon}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\ninterface StepItemProps {\n  step: OnboardingStep;\n  baseId: string;\n  done: boolean;\n  expanded: boolean;\n  pending: boolean;\n  error?: string;\n  reduced: boolean;\n  onToggle: () => void;\n  onExpand: () => void;\n  onRun: () => void;\n}\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`,\n    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 (\n    <motion.li\n      className=\"overflow-hidden\"\n      data-step={step.id}\n      data-done={done || undefined}\n      data-current={expanded || undefined}\n      data-leaving={present ? undefined : \"\"}\n      inert={!present}\n      {...collapse(reduced)}\n    >\n      <div className={cn(stepRow, expanded ? \"bg-hover\" : softHover)}>\n        <CheckButton checked={done} labelledBy={titleId} onToggle={onToggle} reduced={reduced} />\n        <div className=\"grid min-w-0\">\n          <button\n            type=\"button\"\n            className=\"flex min-h-10 w-full cursor-pointer items-center rounded-[10px] border-0 bg-transparent p-0 text-left text-(length:--text-sm) leading-body font-medium text-ink [-webkit-tap-highlight-color:transparent]\"\n            data-title=\"\"\n            aria-expanded={expanded}\n            aria-controls={panelId}\n            onClick={onExpand}\n          >\n            <span id={titleId} className={titleText(done)}>\n              {step.title}\n            </span>\n          </button>\n          <AnimatePresence initial={false}>\n            {expanded ? (\n              <motion.section\n                key=\"panel\"\n                id={panelId}\n                aria-labelledby={titleId}\n                className=\"overflow-hidden\"\n                {...collapse(reduced)}\n              >\n                <div className=\"px-0 pt-0 pb-3\">\n                  {step.description ? (\n                    <p className=\"mx-0 -mt-1 mb-0 max-w-[46ch] text-(length:--text-sm) leading-body text-ink-2\">\n                      {step.description}\n                    </p>\n                  ) : null}\n                  <AnimatePresence initial={false}>\n                    {error ? (\n                      <motion.div key=\"error\" className=\"overflow-hidden\" {...collapse(reduced)}>\n                        <p className=\"m-0 pt-2 text-(length:--text-sm) leading-body text-red\" role=\"alert\">\n                          {error}\n                        </p>\n                      </motion.div>\n                    ) : null}\n                  </AnimatePresence>\n                  {step.actionLabel ? (\n                    <button\n                      type=\"button\"\n                      className=\"mt-3 inline-flex min-h-control-sm cursor-pointer items-center gap-2 rounded-button border-0 bg-ink py-0 pr-3 pl-4 text-(length:--text-sm) font-medium whitespace-nowrap text-page [-webkit-tap-highlight-color:transparent] transition-[box-shadow,scale] duration-[var(--duration-fast),var(--duration-spring)] ease-[var(--ease-standard),var(--ease-spring)] data-[state=done]:cursor-default data-[state=idle]:hover:shadow-resting data-[state=idle]:active:scale-97 data-[state=idle]:active:duration-[var(--duration-fast),100ms] data-[state=idle]:active:ease-standard data-[state=pending]:cursor-progress motion-reduce:transition-none motion-reduce:data-[state=idle]:active:scale-100\"\n                      data-primary=\"\"\n                      data-state={actionState}\n                      aria-disabled={actionState !== \"idle\" || undefined}\n                      aria-busy={pending || undefined}\n                      onClick={onRun}\n                    >\n                      {step.actionLabel}\n                      <ActionIcon state={actionState} reduced={reduced} />\n                    </button>\n                  ) : null}\n                </div>\n              </motion.section>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nfunction CompletedItem({\n  step,\n  baseId,\n  reduced,\n  onToggle,\n}: {\n  step: OnboardingStep;\n  baseId: string;\n  reduced: boolean;\n  onToggle: () => void;\n}) {\n  const present = useIsPresent();\n  const titleId = `${baseId}-${step.id}-done`;\n  return (\n    <motion.li\n      className=\"overflow-hidden\"\n      data-completed-step={step.id}\n      data-done=\"\"\n      inert={!present}\n      {...collapse(reduced)}\n    >\n      <div className={cn(stepRow, softHover)}>\n        <CheckButton checked labelledBy={titleId} onToggle={onToggle} reduced={reduced} />\n        <span className=\"flex min-h-10 min-w-0 items-center text-(length:--text-sm) leading-body font-medium\">\n          <span id={titleId} className={titleText(true)}>\n            {step.title}\n          </span>\n        </span>\n      </div>\n    </motion.li>\n  );\n}\n\nexport function OnboardingChecklist({\n  title,\n  doneTitle = \"You’re all set\",\n  steps,\n  defaultCompleted = [],\n  onCompletedChange,\n  defaultHidden = false,\n  onHiddenChange,\n  className,\n}: 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(() => {\n    reportCompleted.current = onCompletedChange;\n  });\n  useEffect(() => {\n    if (reported.current === state.done) return;\n    reported.current = state.done;\n    reportCompleted.current?.(state.done);\n  }, [state.done]);\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach(clearTimeout);\n  }, []);\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,\n      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\n        ? node.querySelector<HTMLElement>(`[data-step=\"${state.expanded}\"]:not([data-leaving])`)\n        : null;\n      target =\n        next?.querySelector<HTMLElement>(\"[data-primary]\") ??\n        next?.querySelector<HTMLElement>(\"[data-title]\") ??\n        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(\n      id,\n      setTimeout(() => {\n        timers.current.delete(id);\n        if (root.current?.querySelector(`[data-step=\"${id}\"]`)?.contains(document.activeElement))\n          focusRequest.current = \"next\";\n        dispatch({ type: \"settle\", id, steps });\n      }, SETTLE_MS),\n    );\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({\n        type: \"fail\",\n        id: step.id,\n        message: error instanceof Error && error.message ? error.message : \"That didn’t work. Try again.\",\n      });\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 =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? titles.length - 1\n          : (index + (event.key === \"ArrowDown\" ? 1 : -1) + titles.length) % titles.length;\n    event.preventDefault();\n    titles[next]?.focus();\n  }\n\n  const total = steps.length,\n    doneCount = state.done.length,\n    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`,\n    bodyId = `${baseId}-body`,\n    groupId = `${baseId}-completed`;\n  const bodyTransition: Transition = reduced\n    ? { duration: 0 }\n    : state.hidden\n      ? {\n          height: { ...motionTokens.spring.smooth, visualDuration: 0.34 },\n          opacity: { duration: motionTokens.duration.fast, ease: standard },\n        }\n      : {\n          height: motionTokens.spring.smooth,\n          opacity: { duration: motionTokens.duration.standard, ease: enter, delay: 0.05 },\n        };\n\n  return (\n    <section\n      ref={root}\n      className={cn(\"@container w-full min-w-0 rounded-surface border border-line bg-surface text-ink\", className)}\n      aria-labelledby={titleId}\n      data-complete={allDone || undefined}\n      data-hidden={state.hidden || undefined}\n    >\n      <div className=\"grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-x-3 p-5\">\n        <ProgressRing done={doneCount} total={total} reduced={reduced} />\n        <div className=\"grid min-w-0 gap-px\">\n          <h3\n            id={titleId}\n            className=\"m-0 font-body text-(length:--text-base) leading-body font-medium tracking-body text-ink\"\n          >\n            <span className={srOnly}>{allDone ? doneTitle : title}</span>\n            <SwapText text={allDone ? doneTitle : title} options={[title, doneTitle]} reduced={reduced} />\n          </h3>\n          <p className=\"m-0 text-(length:--text-sm) leading-body text-ink-3\">\n            <span className={srOnly}>\n              {doneCount} of {total} done\n            </span>\n            <span className=\"inline-flex items-baseline\" aria-hidden=\"true\">\n              <RollingNumber value={doneCount} reduced={reduced} />\n              &nbsp;of {total} done\n            </span>\n          </p>\n        </div>\n        <button\n          type=\"button\"\n          className=\"inline-flex min-h-control-sm cursor-pointer items-center justify-center gap-1 rounded-button border-0 bg-transparent py-0 pr-2.5 pl-3.5 text-(length:--text-sm) font-medium text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-fast ease-standard hover:bg-hover hover:text-ink active:bg-hover active:text-ink motion-reduce:transition-none @max-[380px]:w-control-sm @max-[380px]:p-0\"\n          aria-expanded={!state.hidden}\n          aria-controls={bodyId}\n          onClick={toggleHidden}\n        >\n          <span className=\"@max-[380px]:hidden\">\n            <SwapText text={state.hidden ? \"Show\" : \"Hide\"} options={[\"Hide\", \"Show\"]} reduced={reduced} />\n          </span>\n          <span className={srOnly}>{state.hidden ? \"Show\" : \"Hide\"} checklist</span>\n          <motion.span\n            className=\"grid place-items-center\"\n            aria-hidden=\"true\"\n            initial={false}\n            animate={{ rotate: state.hidden ? 180 : 0 }}\n            transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n          >\n            <ChevronUp size={16} strokeWidth={1.75} />\n          </motion.span>\n        </button>\n      </div>\n      <motion.div\n        id={bodyId}\n        className=\"overflow-hidden\"\n        initial={false}\n        animate={state.hidden ? { height: 0, opacity: 0 } : { height: \"auto\", opacity: 1 }}\n        transition={bodyTransition}\n        inert={state.hidden}\n      >\n        <div className=\"border-t border-line p-2\">\n          {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- delegated arrow-key navigation between the step title buttons */}\n          <ul\n            className=\"m-0 list-none p-0\"\n            aria-label=\"Steps to do\"\n            aria-hidden={open.length ? undefined : true}\n            onKeyDown={navigate}\n          >\n            <AnimatePresence initial={false}>\n              {open.map((step) => (\n                <StepItem\n                  key={step.id}\n                  step={step}\n                  baseId={baseId}\n                  done={state.done.includes(step.id)}\n                  expanded={state.expanded === step.id}\n                  pending={state.pending.includes(step.id)}\n                  error={state.errors[step.id]}\n                  reduced={reduced}\n                  onToggle={() => (state.done.includes(step.id) ? uncheck(step.id, false) : check(step.id))}\n                  onExpand={() => dispatch({ type: \"expand\", id: step.id, steps })}\n                  onRun={() => run(step)}\n                />\n              ))}\n            </AnimatePresence>\n          </ul>\n          <AnimatePresence initial={false}>\n            {completed.length ? (\n              <motion.div key=\"completed\" className=\"overflow-hidden\" {...collapse(reduced)}>\n                <button\n                  type=\"button\"\n                  className=\"mt-0.5 grid min-h-11 w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-panel border-0 bg-transparent py-0 pr-3 pl-16 text-left text-(length:--text-sm) font-medium text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-fast ease-standard hover:bg-[color-mix(in_oklab,var(--hover)_60%,transparent)] hover:text-ink motion-reduce:transition-none\"\n                  data-group-toggle=\"\"\n                  aria-expanded={state.groupOpen}\n                  aria-controls={groupId}\n                  onClick={() => dispatch({ type: \"group\" })}\n                >\n                  <span className={srOnly}>{completed.length} completed</span>\n                  <span className=\"inline-flex items-baseline\" aria-hidden=\"true\">\n                    <RollingNumber value={completed.length} reduced={reduced} />\n                    &nbsp;completed\n                  </span>\n                  <motion.span\n                    className=\"grid place-items-center\"\n                    aria-hidden=\"true\"\n                    initial={false}\n                    animate={{ rotate: state.groupOpen ? 0 : 180 }}\n                    transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n                  >\n                    <ChevronUp size={16} strokeWidth={1.75} />\n                  </motion.span>\n                </button>\n                <AnimatePresence initial={false}>\n                  {state.groupOpen ? (\n                    <motion.div key=\"list\" id={groupId} className=\"overflow-hidden\" {...collapse(reduced)}>\n                      <ul className=\"m-0 list-none p-0\" aria-label=\"Completed steps\">\n                        <AnimatePresence initial={false}>\n                          {completed.map((step) => (\n                            <CompletedItem\n                              key={step.id}\n                              step={step}\n                              baseId={baseId}\n                              reduced={reduced}\n                              onToggle={() => uncheck(step.id, true)}\n                            />\n                          ))}\n                        </AnimatePresence>\n                      </ul>\n                    </motion.div>\n                  ) : null}\n                </AnimatePresence>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n      <output className={srOnly}>{state.message}</output>\n    </section>\n  );\n}\n\nexport default OnboardingChecklist;\n",
      "type": "registry:component",
      "target": "@components/iris/onboarding-checklist/onboarding-checklist.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/onboarding-checklist",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "OnboardingChecklist",
    "tags": [
      "onboarding",
      "checklist",
      "getting started"
    ],
    "docs": "https://irisui.dev/components/onboarding-checklist"
  }
}
