{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"code-morph","type":"registry:component","title":"Code morph","description":"Glides every surviving token to its new spot as a tutorial moves to the next step.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/code-morph.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: every step is diffed against the last one at token\n   level. Tokens both versions share keep their identity and glide to their\n   new line and column, the way Keynote's Magic Move carries objects between\n   slides, so a reader sees what moved instead of rereading the whole block. */\n\nexport type CodeStep = { label: string; title: string; code: string };\n\ntype Kind =\n  | \"keyword\"\n  | \"string\"\n  | \"number\"\n  | \"comment\"\n  | \"fn\"\n  | \"type\"\n  | \"punct\"\n  | \"plain\";\ntype Tok = { text: string; kind: Kind; row: number; col: number };\ntype Placed = Tok & { id: number; state: \"idle\" | \"stay\" | \"enter\" | \"exit\" };\n\n// Syntax colors are data: they encode token kinds, the same way the swatches\n// in color-swatches are data, so they sit outside the token palette. Each\n// pair keeps its hue across themes at roughly 4.5:1 on the surface.\nconst COLORS: Record<Kind, string | undefined> = {\n  keyword: \"light-dark(oklch(0.5 0.15 300), oklch(0.78 0.12 300))\",\n  string: \"light-dark(oklch(0.5 0.11 150), oklch(0.8 0.12 150))\",\n  number: \"light-dark(oklch(0.54 0.14 50), oklch(0.8 0.12 60))\",\n  fn: \"light-dark(oklch(0.5 0.14 255), oklch(0.78 0.11 250))\",\n  type: \"light-dark(oklch(0.5 0.1 200), oklch(0.8 0.1 195))\",\n  comment: \"var(--muted-foreground)\",\n  punct: \"color-mix(in oklab, var(--foreground) 55%, transparent)\",\n  plain: undefined,\n};\n\nconst KEYWORDS = new Set(\n  \"import from export default function return const let var if else new true false null undefined async await try catch finally throw typeof\".split(\n    \" \",\n  ),\n);\n\n// Alternatives in priority order, so a number inside a string stays part of\n// the string. Multi-character operators come first so `=>` moves as one\n// piece rather than two.\nconst PATTERN =\n  /(\\/\\/[^\\n]*)|(\"[^\"\\n]*\"|'[^'\\n]*'|`[^`]*`)|(\\b\\d+(?:\\.\\d+)?\\b)|([A-Za-z_$][\\w$]*)|(=>|===|!==|&&|\\|\\||\\?\\.|\\.\\.\\.|[^\\s\\w])/g;\n\nfunction tokenize(code: string): Tok[] {\n  const out: Tok[] = [];\n  let row = 0;\n  let col = 0;\n  let last = 0;\n  // Whitespace never becomes a token: it only advances the cursor, so\n  // re-indenting a line moves its tokens rather than replacing them.\n  const advance = (text: string) => {\n    for (const ch of text) {\n      if (ch === \"\\n\") {\n        row++;\n        col = 0;\n      } else col++;\n    }\n  };\n  for (const m of code.matchAll(PATTERN)) {\n    const index = m.index ?? 0;\n    advance(code.slice(last, index));\n    const text = m[0];\n    let kind: Kind = \"plain\";\n    if (m[1]) kind = \"comment\";\n    else if (m[2]) kind = \"string\";\n    else if (m[3]) kind = \"number\";\n    else if (m[5]) kind = \"punct\";\n    else if (KEYWORDS.has(text)) kind = \"keyword\";\n    else if (code[index + text.length] === \"(\") kind = \"fn\";\n    else if (/^[A-Z]/.test(text)) kind = \"type\";\n    out.push({ text, kind, row, col });\n    advance(text);\n    last = index + text.length;\n  }\n  return out;\n}\n\n// Longest common subsequence over token text and kind. Returns, for every\n// token in `b`, the index of its partner in `a` or -1 when it is new.\nfunction match(a: Tok[], b: Tok[]): number[] {\n  const n = a.length;\n  const m = b.length;\n  const w = m + 1;\n  const dp = new Uint16Array((n + 1) * w);\n  const same = (i: number, j: number) =>\n    a[i].text === b[j].text && a[i].kind === b[j].kind;\n  for (let i = n - 1; i >= 0; i--)\n    for (let j = m - 1; j >= 0; j--)\n      dp[i * w + j] = same(i, j)\n        ? dp[(i + 1) * w + j + 1] + 1\n        : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);\n  const pairs = new Array<number>(m).fill(-1);\n  let i = 0;\n  let j = 0;\n  while (i < n && j < m) {\n    if (same(i, j)) {\n      pairs[j] = i;\n      i++;\n      j++;\n    } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) i++;\n    else j++;\n  }\n  return pairs;\n}\n\n// Line height in px; tokens sit on this grid, so it is also the distance a\n// token travels when a line is inserted above it.\nconst LINE = 22;\n// Vertical padding of the code area, top and bottom.\nconst PAD = 16;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// Moves take 480ms, past the usual 300ms UI ceiling on purpose: this is\n// explanatory motion, and the eye has to follow each token to its new\n// place. New tokens wait 300ms so they arrive into space the moves have\n// already opened; removed ones hold their red tint for a beat before going.\nconst STYLES = `\n.cm-token { transition: transform 480ms cubic-bezier(0.77, 0, 0.175, 1), color 200ms ease, background-color 200ms ease; }\n.cm-enter { animation: cm-enter 260ms cubic-bezier(0.23, 1, 0.32, 1) 300ms both; }\n.cm-exit { animation: cm-exit 360ms cubic-bezier(0.23, 1, 0.32, 1) both; }\n@keyframes cm-enter {\n  from { opacity: 0; filter: blur(4px); translate: 0 4px; }\n  to { opacity: 1; filter: none; translate: 0 0; }\n}\n@keyframes cm-exit {\n  0%, 40% { opacity: 1; filter: none; translate: 0 0; }\n  100% { opacity: 0; filter: blur(2px); translate: 0 -3px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .cm-token { transition: color 200ms ease, background-color 200ms ease; }\n  .cm-enter { animation: cm-fade-in 200ms ease 120ms both; }\n  .cm-exit { animation: cm-fade-out 160ms ease both; }\n  @keyframes cm-fade-in { from { opacity: 0; } to { opacity: 1; } }\n  @keyframes cm-fade-out { from { opacity: 1; } to { opacity: 0; } }\n}\n`;\n\nexport function CodeMorph({\n  steps,\n  className,\n}: {\n  steps: CodeStep[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const sources = useMemo(() => steps.map((s) => s.code.trim()), [steps]);\n  const tokens = useMemo(() => sources.map(tokenize), [sources]);\n  const rows = useMemo(() => sources.map((s) => s.split(\"\\n\").length), [sources]);\n  const cols = useMemo(\n    () => Math.max(...sources.flatMap((s) => s.split(\"\\n\").map((l) => l.length))),\n    [sources],\n  );\n  const maxRows = Math.max(...rows);\n\n  const nextId = useRef(tokens[0].length);\n  const [view, setView] = useState<{\n    step: number;\n    items: Placed[];\n    added: Set<number>;\n  }>(() => ({\n    step: 0,\n    items: tokens[0].map((t, i) => ({ ...t, id: i, state: \"idle\" })),\n    added: new Set(),\n  }));\n  const tabs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const go = (next: number) => {\n    if (next === view.step) return;\n    // Tokens still fading out from the last change are dropped: they are\n    // already leaving and would only muddy this diff.\n    const prev = view.items.filter((t) => t.state !== \"exit\");\n    const target = tokens[next];\n    const pairs = match(prev, target);\n    const used = new Set<number>();\n    const added = new Set<number>();\n    const items: Placed[] = target.map((t, j) => {\n      const partner = pairs[j];\n      if (partner >= 0) {\n        used.add(partner);\n        return { ...t, id: prev[partner].id, state: \"stay\" };\n      }\n      added.add(t.row);\n      return { ...t, id: nextId.current++, state: \"enter\" };\n    });\n    prev.forEach((t, i) => {\n      if (!used.has(i)) items.push({ ...t, state: \"exit\" });\n    });\n    setView({ step: next, items, added });\n  };\n\n  const onTabKey = (e: React.KeyboardEvent) => {\n    const last = steps.length - 1;\n    const target = {\n      ArrowRight: view.step === last ? 0 : view.step + 1,\n      ArrowLeft: view.step === 0 ? last : view.step - 1,\n      Home: 0,\n      End: last,\n    }[e.key];\n    if (target === undefined) return;\n    e.preventDefault();\n    go(target);\n    tabs.current[target]?.focus();\n  };\n\n  const current = steps[view.step];\n  const code = sources[view.step];\n\n  return (\n    <div\n      className={cn(\n        \"w-[580px] max-w-full overflow-hidden rounded-2xl bg-muted shadow-raised\",\n        className,\n      )}\n    >\n      <style>{STYLES}</style>\n      <div className=\"flex items-center justify-between gap-3 border-b border-border p-2\">\n        <div\n          role=\"tablist\"\n          aria-label=\"Tutorial steps\"\n          onKeyDown={onTabKey}\n          className=\"flex items-center gap-1\"\n        >\n          {steps.map((s, i) => {\n            const selected = i === view.step;\n            return (\n              <button\n                key={s.label}\n                ref={(el) => {\n                  tabs.current[i] = el;\n                }}\n                type=\"button\"\n                role=\"tab\"\n                id={`${id}-tab-${i}`}\n                aria-selected={selected}\n                aria-controls={`${id}-panel`}\n                tabIndex={selected ? 0 : -1}\n                onClick={() => go(i)}\n                className={cn(\n                  \"relative h-9 touch-manipulation rounded-lg px-3 text-sm font-medium outline-hidden select-none\",\n                  \"transition-[scale,color] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[color]\",\n                  \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground\",\n                  selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {selected && (\n                  <motion.span\n                    layoutId={`${id}-pill`}\n                    transition={\n                      reduceMotion\n                        ? { duration: 0 }\n                        : { type: \"spring\", visualDuration: 0.25, bounce: 0.1 }\n                    }\n                    className=\"absolute inset-0 rounded-lg bg-background shadow-raised\"\n                  />\n                )}\n                <span className=\"relative\">{s.label}</span>\n              </button>\n            );\n          })}\n        </div>\n        <CopyCode value={code} />\n      </div>\n\n      <div\n        role=\"tabpanel\"\n        id={`${id}-panel`}\n        aria-labelledby={`${id}-tab-${view.step}`}\n        tabIndex={0}\n        className=\"overflow-x-auto overflow-y-hidden outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n      >\n        <pre className=\"sr-only\">{code}</pre>\n        <div\n          aria-hidden\n          className=\"relative flex font-mono text-[13px] transition-[height] duration-[480ms] ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none\"\n          // Fits the current step, so a short step leaves no empty editor\n          // below it. The tabs sit above and never move; only the footer\n          // rides the edge down, in step with the lines that push it.\n          style={{\n            height: rows[view.step] * LINE + PAD * 2,\n            lineHeight: `${LINE}px`,\n            paddingBlock: PAD,\n          }}\n        >\n          {/* Rows that gained code in this step, so the reader knows where\n              to look once the motion settles. */}\n          {Array.from({ length: maxRows }, (_, r) => (\n            <span\n              key={r}\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 z-20 border-l-2 border-foreground/25 bg-foreground/[0.04]\",\n                \"transition-opacity ease-out\",\n                view.added.has(r)\n                  ? \"opacity-100 delay-300 duration-300\"\n                  : \"opacity-0 duration-150\",\n              )}\n              style={{ top: PAD + r * LINE, height: LINE }}\n            />\n          ))}\n          <div className=\"sticky left-0 z-10 shrink-0 bg-muted pr-4 pl-4 text-right text-muted-foreground/60 tabular-nums select-none\">\n            {Array.from({ length: maxRows }, (_, r) => (\n              <div\n                key={r}\n                className={cn(\n                  \"transition-opacity duration-200 ease-out\",\n                  r < rows[view.step] ? \"opacity-100\" : \"opacity-0\",\n                )}\n              >\n                {r + 1}\n              </div>\n            ))}\n          </div>\n          <div\n            className=\"relative shrink-0 whitespace-pre\"\n            style={{ width: `calc(${cols}ch + 20px)` }}\n          >\n            {view.items.map((t) => (\n              <span\n                key={t.id}\n                className={cn(\n                  \"cm-token absolute top-0 left-0 rounded-[3px]\",\n                  t.state === \"enter\" && \"cm-enter\",\n                  t.state === \"exit\" && \"cm-exit bg-destructive/12\",\n                )}\n                style={{\n                  transform: `translate(${t.col}ch, ${t.row * LINE}px)`,\n                  color: t.state === \"exit\" ? \"var(--destructive)\" : COLORS[t.kind],\n                }}\n              >\n                {t.text}\n              </span>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex h-12 items-center gap-3 border-t border-border px-4 text-sm\">\n        <span className=\"shrink-0 font-medium text-muted-foreground tabular-nums\">\n          {view.step + 1}/{steps.length}\n        </span>\n        <div className=\"relative min-w-0 flex-1\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.p\n              key={view.step}\n              initial={{ opacity: 0, filter: \"blur(4px)\", y: reduceMotion ? 0 : 4 }}\n              animate={{\n                opacity: 1,\n                filter: \"blur(0px)\",\n                y: 0,\n                transition: { duration: 0.24, ease: EASE_OUT },\n              }}\n              exit={{\n                opacity: 0,\n                filter: \"blur(2px)\",\n                transition: { duration: 0.12, ease: EASE_OUT },\n              }}\n              className=\"truncate text-foreground\"\n            >\n              {current.title}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction CopyCode({ value }: { value: string }) {\n  const [status, setStatus] = useState<\"idle\" | \"copied\" | \"failed\">(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const attempt = useRef(0);\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const show = (next: \"copied\" | \"failed\") => {\n    setStatus(next);\n    clearTimeout(timer.current);\n    // Long enough to notice the check, short enough to copy again soon.\n    timer.current = setTimeout(() => setStatus(\"idle\"), 1600);\n  };\n\n  const copy = async () => {\n    const n = ++attempt.current;\n    // Confirm on press: the write is near instant and waiting reads as lag.\n    show(\"copied\");\n    try {\n      if (!navigator.clipboard) throw new Error(\"Clipboard unavailable\");\n      await navigator.clipboard.writeText(value);\n    } catch {\n      if (n === attempt.current) show(\"failed\");\n    }\n  };\n\n  const done = status === \"copied\";\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={copy}\n        aria-label=\"Copy this step\"\n        className={cn(\n          \"relative grid size-9 shrink-0 touch-manipulation place-items-center rounded-lg text-muted-foreground outline-hidden hover:text-foreground\",\n          \"transition-[scale,color] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[color]\",\n          \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground\",\n        )}\n      >\n        {/* Both icons share one grid cell, so the outgoing one stays centred\n            under the incoming one. popLayout re-measured the exit mid-press\n            and let the two sit side by side in the flex row. */}\n        <AnimatePresence initial={false}>\n          <motion.svg\n            key={done ? \"check\" : \"copy\"}\n            initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n            transition={ICON_SWAP}\n            viewBox=\"0 0 16 16\"\n            className=\"col-start-1 row-start-1 size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            {done ? (\n              <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" />\n            ) : (\n              <>\n                <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n                <path d=\"M10.75 5.25V4a1.25 1.25 0 0 0-1.25-1.25H4A1.25 1.25 0 0 0 2.75 4v5.5A1.25 1.25 0 0 0 4 10.75h1.25\" />\n              </>\n            )}\n          </motion.svg>\n        </AnimatePresence>\n      </button>\n      <span role=\"status\" className=\"sr-only\">\n        {status === \"copied\" ? \"Copied\" : status === \"failed\" ? \"Couldn't copy\" : \"\"}\n      </span>\n    </>\n  );\n}\n\nconst STEPS: CodeStep[] = [\n  {\n    label: \"Step 1\",\n    title: \"Fetch the user in an effect\",\n    code: `\nfunction Profile({ id }) {\n  const [user, setUser] = useState(null);\n\n  useEffect(() => {\n    fetch(\\`/api/users/\\${id}\\`)\n      .then((res) => res.json())\n      .then(setUser);\n  }, [id]);\n\n  return <Card user={user} />;\n}`,\n  },\n  {\n    label: \"Step 2\",\n    title: \"Show a spinner while it loads\",\n    code: `\nfunction Profile({ id }) {\n  const [user, setUser] = useState(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    setLoading(true);\n    fetch(\\`/api/users/\\${id}\\`)\n      .then((res) => res.json())\n      .then(setUser)\n      .finally(() => setLoading(false));\n  }, [id]);\n\n  if (loading) return <Spinner />;\n  return <Card user={user} />;\n}`,\n  },\n  {\n    label: \"Step 3\",\n    title: \"Abort the stale request on a new id\",\n    code: `\nfunction Profile({ id }) {\n  const [user, setUser] = useState(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const controller = new AbortController();\n    setLoading(true);\n    fetch(\\`/api/users/\\${id}\\`, { signal: controller.signal })\n      .then((res) => res.json())\n      .then(setUser)\n      .finally(() => setLoading(false));\n    return () => controller.abort();\n  }, [id]);\n\n  if (loading) return <Spinner />;\n  return <Card user={user} />;\n}`,\n  },\n];\n\nexport default function CodeMorphDemo() {\n  return <CodeMorph steps={STEPS} />;\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":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/code-morph"}