{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"onboarding-checklist","type":"registry:component","title":"Onboarding checklist","description":"A setup checklist that counts up as you go and celebrates the finish.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/onboarding-checklist.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useSpring, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Task = {\n  id: string;\n  title: string;\n  description: string;\n  action: string;\n};\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Slower than a UI transition on purpose: the ring and its number are the\n// reward, and half a second is long enough to watch them count up. No\n// bounce, so the number never reads past 100.\nconst RING = { visualDuration: 0.5, bounce: 0 };\n// Lets the last check and the ring reaching 100 land before the card\n// changes, so the finish is seen rather than skipped.\nconst CELEBRATE_AFTER = 700;\nconst RADIUS = 20;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nexport function OnboardingChecklist({\n  tasks,\n  initialDone = [],\n  initialOpen = [],\n  onDismiss,\n  className,\n}: {\n  tasks: Task[];\n  initialDone?: string[];\n  initialOpen?: string[];\n  onDismiss?: () => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [done, setDone] = useState(() => new Set(initialDone));\n  const [open, setOpen] = useState(() => new Set(initialOpen));\n  const [celebrating, setCelebrating] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const titleId = useId();\n\n  const count = tasks.filter((t) => done.has(t.id)).length;\n  const complete = count === tasks.length;\n  const progress = count / tasks.length;\n\n  const ring = useSpring(progress, RING);\n  const percent = useTransform(ring, (v) => Math.round(v * 100));\n  useEffect(() => {\n    if (reduceMotion) ring.jump(progress);\n    else ring.set(progress);\n  }, [progress, reduceMotion, ring]);\n\n  useEffect(() => {\n    if (!complete) return;\n    const id = setTimeout(() => {\n      setCelebrating(true);\n      setAnnouncement(\"All tasks complete. You're all set.\");\n    }, CELEBRATE_AFTER);\n    return () => clearTimeout(id);\n  }, [complete]);\n\n  const toggle = (task: Task, value: boolean) => {\n    const next = new Set(done);\n    if (value) next.add(task.id);\n    else next.delete(task.id);\n    setDone(next);\n    const total = tasks.filter((t) => next.has(t.id)).length;\n    setAnnouncement(\n      `${task.title} ${value ? \"done\" : \"not done\"}. ${total} of ${tasks.length} complete.`,\n    );\n  };\n\n  const toggleOpen = (id: string) =>\n    setOpen((o) => {\n      const next = new Set(o);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={cn(\n        \"w-[min(400px,100%)] overflow-hidden rounded-[20px] bg-muted text-foreground shadow-raised\",\n        className,\n      )}\n    >\n      <header className=\"flex items-center justify-between gap-4 p-5 pb-3\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h2 id={titleId} className=\"text-lg font-semibold tracking-[-0.01em]\">\n            Get started\n          </h2>\n          <p className=\"text-sm text-muted-foreground tabular-nums\">\n            {count} of {tasks.length} complete\n          </p>\n        </div>\n        <div\n          role=\"progressbar\"\n          aria-label=\"Setup progress\"\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={Math.round(progress * 100)}\n          className=\"relative grid size-12 shrink-0 place-items-center\"\n        >\n          <svg viewBox=\"0 0 48 48\" className=\"absolute inset-0 size-full -rotate-90\" aria-hidden>\n            <circle cx={24} cy={24} r={RADIUS} fill=\"none\" strokeWidth={4} className=\"stroke-foreground/10\" />\n            <motion.circle\n              cx={24}\n              cy={24}\n              r={RADIUS}\n              fill=\"none\"\n              strokeWidth={4}\n              strokeLinecap=\"round\"\n              className=\"stroke-foreground\"\n              style={{ pathLength: ring }}\n            />\n          </svg>\n          <span className=\"relative text-xs font-medium tabular-nums\">\n            <motion.span>{percent}</motion.span>\n            <span className=\"text-muted-foreground\">%</span>\n          </span>\n        </div>\n      </header>\n\n      {/* The list and the finished state swap by collapsing one and opening\n          the other, so the card only ever changes height below its header. */}\n      <Collapse open={!celebrating}>\n        <ul className=\"px-2 pb-2\">\n          {tasks.map((task) => (\n            <TaskRow\n              key={task.id}\n              task={task}\n              done={done.has(task.id)}\n              open={open.has(task.id)}\n              reduceMotion={!!reduceMotion}\n              onToggle={(v) => toggle(task, v)}\n              onToggleOpen={() => toggleOpen(task.id)}\n            />\n          ))}\n        </ul>\n      </Collapse>\n\n      <Collapse open={celebrating}>\n        <div className=\"px-5 pt-1 pb-5\">\n          {celebrating && (\n            <Celebration reduceMotion={!!reduceMotion} onDismiss={onDismiss} />\n          )}\n        </div>\n      </Collapse>\n\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </p>\n    </section>\n  );\n}\n\nfunction Collapse({\n  open,\n  children,\n}: {\n  open: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <div\n      inert={!open}\n      className={cn(\n        \"grid transition-[grid-template-rows] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n        open ? \"grid-rows-[1fr] duration-300\" : \"grid-rows-[0fr] duration-200\",\n      )}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{children}</div>\n    </div>\n  );\n}\n\nfunction TaskRow({\n  task,\n  done,\n  open,\n  reduceMotion,\n  onToggle,\n  onToggleOpen,\n}: {\n  task: Task;\n  done: boolean;\n  open: boolean;\n  reduceMotion: boolean;\n  onToggle: (value: boolean) => void;\n  onToggleOpen: () => void;\n}) {\n  const detailsId = `${useId()}-details`;\n  return (\n    <li>\n      <div className=\"flex items-center\">\n        {/* A 40px hit area around the 20px box. */}\n        <label className=\"group relative grid size-10 shrink-0 cursor-pointer place-items-center\">\n          <input\n            type=\"checkbox\"\n            checked={done}\n            onChange={(e) => onToggle(e.target.checked)}\n            aria-label={task.title}\n            className=\"peer absolute inset-0 cursor-pointer opacity-0\"\n          />\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none grid size-5 place-items-center rounded-md border-[1.5px] transition-[background-color,border-color,scale] duration-150 ease-out group-active:scale-[0.96] peer-focus-visible:outline-2 peer-focus-visible:outline-solid peer-focus-visible:outline-offset-2 peer-focus-visible:outline-foreground motion-reduce:transition-[background-color,border-color]\",\n              done\n                ? \"border-foreground bg-foreground text-background\"\n                : \"border-muted-foreground/60 [@media(hover:hover)]:group-hover:border-muted-foreground\",\n            )}\n          >\n            <motion.svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-3.5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={false}\n              animate={\n                done\n                  ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n                  : reduceMotion\n                    ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n              }\n              transition={ICON_SWAP}\n            >\n              <path d=\"m3.5 8.5 3 3 6-7\" />\n            </motion.svg>\n          </span>\n        </label>\n\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={detailsId}\n          onClick={onToggleOpen}\n          className=\"flex h-12 min-w-0 flex-1 items-center justify-between gap-3 rounded-[10px] pr-3 pl-1 text-left outline-hidden transition-[background-color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:bg-foreground/[0.06] [@media(hover:hover)]:hover:bg-foreground/[0.04]\"\n        >\n          <span\n            className={cn(\n              \"relative min-w-0 truncate text-[15px] transition-[color] duration-200 ease-out\",\n              done ? \"text-muted-foreground\" : \"text-foreground\",\n            )}\n          >\n            {task.title}\n            {/* Draws left to right when checked and retracts faster when\n                unchecked: slow where it rewards, quick where it undoes. */}\n            <span\n              aria-hidden\n              className={cn(\n                \"absolute top-1/2 left-0 h-px w-full origin-left bg-current transition-[scale] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n                done ? \"scale-x-100 duration-300\" : \"scale-x-0 duration-150\",\n              )}\n            />\n          </span>\n          <svg\n            viewBox=\"0 0 16 16\"\n            className={cn(\n              \"size-4 shrink-0 text-muted-foreground transition-[rotate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n              open && \"rotate-180\",\n            )}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            <path d=\"m4 6 4 4 4-4\" />\n          </svg>\n        </button>\n      </div>\n\n      {/* 0fr to 1fr animates to the content's real height. The row above\n          never moves; only what's below it slides. */}\n      <div\n        id={detailsId}\n        inert={!open}\n        className={cn(\n          \"grid transition-[grid-template-rows] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n          open ? \"grid-rows-[1fr] duration-250\" : \"grid-rows-[0fr] duration-200\",\n        )}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div\n            className={cn(\n              \"flex flex-col items-start gap-3 pr-3 pb-4 pl-11 transition-[opacity,filter,translate] ease-out motion-reduce:transition-[opacity]\",\n              open\n                ? \"translate-y-0 opacity-100 blur-[0px] duration-250\"\n                : \"-translate-y-1 opacity-0 blur-[4px] duration-150\",\n            )}\n          >\n            <p className=\"text-sm leading-5 text-pretty text-muted-foreground\">{task.description}</p>\n            <button\n              type=\"button\"\n              aria-disabled={done}\n              onClick={() => {\n                if (!done) onToggle(true);\n              }}\n              className={cn(\n                \"grid h-9 rounded-full px-4 text-sm font-medium outline-hidden transition-[background-color,color,scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n                done\n                  ? \"cursor-default bg-foreground/[0.06] text-muted-foreground\"\n                  : \"bg-foreground text-background active:scale-[0.96]\",\n              )}\n            >\n              {/* Both labels share a cell so the button keeps its width. */}\n              <ActionLabel visible={!done}>{task.action}</ActionLabel>\n              <ActionLabel visible={done} reduceMotion={reduceMotion} icon>\n                Done\n              </ActionLabel>\n            </button>\n          </div>\n        </div>\n      </div>\n    </li>\n  );\n}\n\nfunction ActionLabel({\n  visible,\n  icon,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  icon?: boolean;\n  reduceMotion?: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5\"\n    >\n      {icon && (\n        <motion.svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={\n            visible\n              ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n              : reduceMotion\n                ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n          }\n          transition={ICON_SWAP}\n        >\n          <path d=\"m3.5 8.5 3 3 6-7\" />\n        </motion.svg>\n      )}\n      <span\n        className={cn(\n          \"transition-[opacity,filter] duration-200 ease-out\",\n          !visible && \"opacity-0 blur-[4px]\",\n        )}\n      >\n        {children}\n      </span>\n    </span>\n  );\n}\n\nfunction Celebration({\n  reduceMotion,\n  onDismiss,\n}: {\n  reduceMotion: boolean;\n  onDismiss?: () => void;\n}) {\n  // A rare, one-time moment, so it gets a gentle staggered entrance: the\n  // heading, then the note, then the button, each 80ms apart.\n  const enter = (i: number) => ({\n    initial: reduceMotion\n      ? { opacity: 0 }\n      : { opacity: 0, y: 6, filter: \"blur(4px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    transition: { duration: 0.4, ease: EASE_OUT, delay: 0.12 + i * 0.08 },\n  });\n  return (\n    <div className=\"flex flex-col items-start gap-1\">\n      <motion.h3 {...enter(0)} className=\"text-[17px] font-semibold tracking-[-0.01em]\">\n        You&apos;re all set\n      </motion.h3>\n      <motion.p {...enter(1)} className=\"text-sm leading-5 text-pretty text-muted-foreground\">\n        Your workspace is ready. You can find these steps again any time in Settings.\n      </motion.p>\n      <motion.div {...enter(2)} className=\"mt-3\">\n        <button\n          type=\"button\"\n          onClick={onDismiss}\n          className=\"h-9 rounded-full bg-foreground px-4 text-sm font-medium text-background outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n        >\n          Dismiss\n        </button>\n      </motion.div>\n    </div>\n  );\n}\n\nconst TASKS: Task[] = [\n  {\n    id: \"project\",\n    title: \"Create your first project\",\n    description: \"Projects keep pages, settings and people together in one place.\",\n    action: \"New project\",\n  },\n  {\n    id: \"invite\",\n    title: \"Invite a teammate\",\n    description: \"Work goes faster together. Each teammate gets their own login.\",\n    action: \"Send invite\",\n  },\n  {\n    id: \"github\",\n    title: \"Connect GitHub\",\n    description: \"Link a repository so commits and pull requests show up here.\",\n    action: \"Connect\",\n  },\n  {\n    id: \"notifications\",\n    title: \"Set up notifications\",\n    description: \"Choose where you hear about mentions and reviews: email, desktop or both.\",\n    action: \"Choose channels\",\n  },\n  {\n    id: \"workspace\",\n    title: \"Personalize your workspace\",\n    description: \"Add a logo and a name so the space feels like yours.\",\n    action: \"Open settings\",\n  },\n];\n\nexport default function OnboardingChecklistDemo() {\n  const [dismissed, setDismissed] = useState(false);\n  // A new key remounts the card fresh after \"Show checklist again\".\n  const [round, setRound] = useState(0);\n  const restoreRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    if (dismissed) restoreRef.current?.focus();\n  }, [dismissed]);\n\n  return (\n    <div className=\"relative flex w-[min(400px,100%)] flex-col items-center\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {dismissed ? (\n          <motion.button\n            key=\"restore\"\n            ref={restoreRef}\n            type=\"button\"\n            onClick={() => {\n              setRound((r) => r + 1);\n              setDismissed(false);\n            }}\n            initial={{ opacity: 0, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.25, ease: EASE_OUT }}\n            className=\"h-9 rounded-full bg-muted px-4 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            Show checklist again\n          </motion.button>\n        ) : (\n          <motion.div\n            key={`card-${round}`}\n            className=\"w-full\"\n            initial={{ opacity: 0, y: -4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            // Leaves faster and smaller than it arrives.\n            exit={{ opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.15, ease: EASE_OUT } }}\n            transition={{ duration: 0.25, ease: EASE_OUT }}\n          >\n            <OnboardingChecklist\n              tasks={TASKS}\n              initialDone={[\"project\"]}\n              initialOpen={[\"invite\"]}\n              onDismiss={() => setDismissed(true)}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/onboarding-checklist"}