{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"code-block","type":"registry:component","title":"Code block","description":"Switch files with a sliding underline and copy with one click.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/code-block.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CodeFile = { name: string; language: \"tsx\" | \"css\"; code: string };\n\ntype Kind = \"keyword\" | \"string\" | \"comment\" | \"number\" | \"plain\";\ntype Token = { kind: Kind; text: string };\n\n// Syntax colors are data, like the swatches in color-swatches: they encode\n// token kinds rather than decorate, so they sit outside the token palette.\n// Each pair keeps the same hue and roughly 4.5:1 against the surface in both\n// themes; comments and plain text stay on the muted and foreground tokens.\nconst COLORS: Record<Kind, string | undefined> = {\n  keyword: \"light-dark(oklch(0.5 0.14 285), oklch(0.78 0.11 285))\",\n  string: \"light-dark(oklch(0.5 0.1 155), oklch(0.8 0.1 155))\",\n  number: \"light-dark(oklch(0.5 0.14 285), oklch(0.78 0.11 285))\",\n  comment: \"var(--muted-foreground)\",\n  plain: undefined,\n};\n\nconst TS_KEYWORDS = new Set(\n  \"import from export default function return const let type interface if else as new true false null undefined async await\".split(\n    \" \",\n  ),\n);\n\n// One pass per language. Earlier alternatives win, so a number inside a\n// string or comment stays part of that string or comment.\n// Each group maps, in order, to the kind in GROUPS. A TS word is only a\n// keyword when it is in TS_KEYWORDS; a CSS keyword is an at-rule or a\n// property name at the start of a line.\nconst PATTERNS = {\n  tsx: /(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)|(\"[^\"\\n]*\"|'[^'\\n]*'|`[^`]*`)|(\\b\\d+(?:\\.\\d+)?\\b)|([A-Za-z_$][\\w$]*)/g,\n  css: /(\\/\\*[\\s\\S]*?\\*\\/)|(\"[^\"\\n]*\"|'[^'\\n]*')|(\\b\\d+(?:\\.\\d+)?(?:px|ms|s|rem|em|%)?)|(@[\\w-]+|(?<=^[ \\t]*)[a-z-]+(?=\\s*:))/gm,\n};\nconst GROUPS = [\"comment\", \"string\", \"number\", \"word\"] as const;\n\nfunction tokenize(code: string, language: CodeFile[\"language\"]): Token[][] {\n  const tokens: Token[] = [];\n  let last = 0;\n  for (const match of code.matchAll(PATTERNS[language])) {\n    const index = match.index ?? 0;\n    if (index > last) tokens.push({ kind: \"plain\", text: code.slice(last, index) });\n    const group = GROUPS[match.slice(1).findIndex((g) => g !== undefined)];\n    const kind: Kind =\n      group !== \"word\"\n        ? group\n        : language === \"css\" || TS_KEYWORDS.has(match[0])\n          ? \"keyword\"\n          : \"plain\";\n    tokens.push({ kind, text: match[0] });\n    last = index + match[0].length;\n  }\n  if (last < code.length) tokens.push({ kind: \"plain\", text: code.slice(last) });\n\n  // Split into lines, cutting any token that spans one, like a block comment.\n  const lines: Token[][] = [[]];\n  for (const token of tokens) {\n    token.text.split(\"\\n\").forEach((part, i) => {\n      if (i > 0) lines.push([]);\n      if (part) lines[lines.length - 1].push({ kind: token.kind, text: part });\n    });\n  }\n  return lines;\n}\n\nexport function CodeBlock({ files, className }: { files: CodeFile[]; className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [active, setActive] = useState(0);\n  const tabs = useRef<(HTMLButtonElement | null)[]>([]);\n  const highlighted = useMemo(() => files.map((f) => tokenize(f.code.trim(), f.language)), [files]);\n\n  return (\n    <div\n      className={cn(\n        \"w-[520px] max-w-full overflow-hidden rounded-2xl bg-muted shadow-raised\",\n        className,\n      )}\n    >\n      <div className=\"flex h-11 items-stretch justify-between border-b border-border pr-2 pl-2\">\n        <LayoutGroup id={id}>\n          <div role=\"tablist\" aria-label=\"Files\" className=\"flex min-w-0\">\n            {files.map((file, i) => {\n              const selected = i === active;\n              return (\n                <button\n                  key={file.name}\n                  ref={(el) => {\n                    tabs.current[i] = el;\n                  }}\n                  id={`${id}-tab-${i}`}\n                  type=\"button\"\n                  role=\"tab\"\n                  aria-selected={selected}\n                  aria-controls={`${id}-panel-${i}`}\n                  tabIndex={selected ? 0 : -1}\n                  onClick={() => setActive(i)}\n                  onKeyDown={(e) => {\n                    const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];\n                    const target =\n                      step !== undefined\n                        ? (i + step + files.length) % files.length\n                        : e.key === \"Home\"\n                          ? 0\n                          : e.key === \"End\"\n                            ? files.length - 1\n                            : null;\n                    if (target === null) return;\n                    e.preventDefault();\n                    setActive(target);\n                    tabs.current[target]?.focus();\n                  }}\n                  className={cn(\n                    \"group relative touch-manipulation px-3 font-mono text-[13px] outline-hidden transition-[color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-4 focus-visible:outline-foreground\",\n                    selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                  )}\n                >\n                  {/* Press feedback on the label only, so the underline is\n                      never measured mid-scale when the tab changes. */}\n                  <span className=\"inline-block transition-[scale] duration-150 ease-out group-active:scale-[0.96] motion-reduce:transition-none\">\n                    {file.name}\n                  </span>\n                  {selected && (\n                    <motion.span\n                      layoutId=\"code-tab-underline\"\n                      transition={\n                        reduceMotion ? { duration: 0 } : { type: \"spring\", duration: 0.3, bounce: 0 }\n                      }\n                      // Sits on the header's border so the two read as one line.\n                      className=\"absolute inset-x-3 -bottom-px h-0.5 rounded-full bg-foreground\"\n                    />\n                  )}\n                </button>\n              );\n            })}\n          </div>\n        </LayoutGroup>\n        <div className=\"flex items-center\">\n          <CopyCode value={files[active].code.trim()} />\n        </div>\n      </div>\n\n      {/* Every file keeps its own scroll box, stacked in one fixed-height\n          cell, so switching is a true crossfade and each file remembers\n          where you had scrolled to. */}\n      <div className=\"relative h-[280px]\">\n        {files.map((file, i) => {\n          const selected = i === active;\n          return (\n            <div\n              key={file.name}\n              id={`${id}-panel-${i}`}\n              role=\"tabpanel\"\n              aria-labelledby={`${id}-tab-${i}`}\n              tabIndex={selected ? 0 : -1}\n              inert={!selected}\n              className={cn(\n                \"absolute inset-0 overflow-auto overscroll-contain outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\",\n                \"transition-[opacity,filter] ease-out motion-reduce:transition-[opacity]\",\n                selected\n                  ? \"opacity-100 blur-[0px] duration-200\"\n                  : \"opacity-0 blur-[4px] duration-150 motion-reduce:blur-[0px]\",\n              )}\n            >\n              <pre className=\"min-w-max py-3 font-mono text-[14px] leading-6\">\n                <code>\n                  {highlighted[i].map((line, n) => (\n                    <div key={n} className=\"flex\">\n                      {/* Sticky, so numbers stay put while long lines scroll\n                          sideways underneath them. */}\n                      <span\n                        aria-hidden\n                        className=\"sticky left-0 w-11 shrink-0 bg-muted pr-4 text-right text-muted-foreground/60 tabular-nums select-none\"\n                      >\n                        {n + 1}\n                      </span>\n                      <span className=\"pr-5 whitespace-pre text-foreground\">\n                        {line.map((token, t) => (\n                          <span key={t} style={{ color: COLORS[token.kind] }}>\n                            {token.text}\n                          </span>\n                        ))}\n                        {/* Keeps empty lines one row tall. */}\n                        {line.length === 0 && \" \"}\n                      </span>\n                    </div>\n                  ))}\n                </code>\n              </pre>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\ntype Status = \"idle\" | \"copied\" | \"failed\";\n\nfunction CopyCode({ value }: { value: string }) {\n  const reduceMotion = useReducedMotion();\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const attempt = useRef(0);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const show = (next: Status) => {\n    setStatus(next);\n    clearTimeout(timer.current);\n    // Long enough to read the label, short enough to copy again soon.\n    timer.current = setTimeout(() => setStatus(\"idle\"), 1600);\n  };\n\n  const copy = async () => {\n    const id = ++attempt.current;\n    // Confirms on press; the write is near instant and waiting for it makes\n    // the click feel ignored.\n    show(\"copied\");\n    try {\n      if (!navigator.clipboard) throw new Error(\"Clipboard unavailable\");\n      await navigator.clipboard.writeText(value);\n    } catch {\n      // A newer click has already taken over the label.\n      if (id === attempt.current) show(\"failed\");\n    }\n  };\n\n  const labels: Record<Status, string> = { idle: \"Copy\", copied: \"Copied\", failed: \"Failed\" };\n\n  return (\n    <button\n      type=\"button\"\n      onClick={copy}\n      aria-label=\"Copy code\"\n      className={cn(\n        \"relative flex h-8 touch-manipulation items-center gap-1.5 rounded-lg px-2.5 text-[13px] text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out select-none hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n        // Grows the hit area to 40px tall without growing the button.\n        \"after:absolute after:-inset-1\",\n        status !== \"idle\" && \"text-foreground\",\n      )}\n    >\n      <span className=\"grid\" aria-hidden>\n        <Icon visible={status !== \"copied\"} reduceMotion={reduceMotion}>\n          <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n          <path d=\"M10.75 5.25V4.5a1.75 1.75 0 0 0-1.75-1.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n        </Icon>\n        <Icon visible={status === \"copied\"} reduceMotion={reduceMotion}>\n          <path d=\"m3.5 8.5 3 3 6-7\" />\n        </Icon>\n      </span>\n      {/* All three labels share one cell, so the button keeps the width of\n          the longest and never jumps. */}\n      <span className=\"grid\" aria-hidden>\n        {(Object.keys(labels) as Status[]).map((key) => (\n          <span\n            key={key}\n            className={cn(\n              \"col-start-1 row-start-1 transition-[opacity,filter] ease-out\",\n              key === status ? \"opacity-100 blur-[0px] duration-200\" : \"opacity-0 blur-[4px] duration-150\",\n            )}\n          >\n            {labels[key]}\n          </span>\n        ))}\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"copied\" ? \"Copied\" : status === \"failed\" ? \"Couldn't copy\" : \"\"}\n      </span>\n    </button>\n  );\n}\n\nfunction Icon({\n  visible,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  reduceMotion: boolean | null;\n  children: React.ReactNode;\n}) {\n  const hidden = reduceMotion ? { opacity: 0 } : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.svg\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      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\nconst FILES: CodeFile[] = [\n  {\n    name: \"button.tsx\",\n    language: \"tsx\",\n    code: `\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonProps = React.ComponentProps<\"button\">;\n\n// Scales to 0.96 on press, so the click feels physical.\nexport function Button({ className, ...props }: ButtonProps) {\n  return (\n    <button\n      className={cn(\"h-10 rounded-full px-4 transition-[scale] duration-150 active:scale-[0.96]\", className)}\n      {...props}\n    />\n  );\n}\n\nexport default function Demo() {\n  return <Button onClick={() => console.log(42)}>Save</Button>;\n}\n`,\n  },\n  {\n    name: \"styles.css\",\n    language: \"css\",\n    code: `\n/* Strong ease-out: responds at once, then settles. */\n.button {\n  height: 40px;\n  padding: 0 16px;\n  border-radius: 999px;\n  transition: scale 150ms cubic-bezier(0.23, 1, 0.32, 1), background-color 150ms ease;\n}\n\n.button:active {\n  scale: 0.96;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .button {\n    transition: background-color 150ms ease;\n  }\n}\n`,\n  },\n];\n\nexport default function CodeBlockDemo() {\n  return <CodeBlock files={FILES} />;\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-block"}