{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"code-block","type":"registry:component","title":"Code block","description":"Code in an answer: highlighted as it streams, copy and wrap, long files folded, and an optional Apply.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/status-button.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/code-block.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown, Copy, CornerDownLeft, WrapText } from \"lucide-react\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CODE BLOCK: code in an answer, as it streams and after\n *\n *   streaming  lines arrive with a caret, already highlighted\n *   complete   language and file name, Copy and wrap; wrapping\n *              eases the block to its new height\n *   copied     the copy icon blurs into a check that draws\n *              itself while \"Copy\" morphs to \"Copied\"\n *   folded     long code shows its start and \"Show 42 more\n *              lines\"; it opens smoothly, the fade dissolves and\n *              the label morphs to \"Show less\"\n *   apply      optional: Apply → Applying → Applied, one button\n *              that settles into a quiet receipt\n *\n * A light highlighter is built in (keywords, strings, comments,\n * numbers, calls) so it works mid-stream with no dependencies.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {\n  code: string;\n  /** e.g. \"ts\", \"python\", \"bash\"; shown in the header and used for comments. */\n  language?: string;\n  filename?: string;\n  /** True while the code is still arriving. */\n  streaming?: boolean;\n  lineNumbers?: boolean;\n  /** Fold code longer than this many lines (0 never folds). */\n  collapseAfter?: number;\n  /** Shows Apply; return a promise to show progress. */\n  onApply?: () => void | Promise<void>;\n  applyLabel?: string;\n  /** Turn the built-in highlighting off, e.g. for plain output. */\n  highlight?: boolean;\n}\n\nconst LINE = 21;\nconst PAD = 12;\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst COPY_LABELS = { idle: \"Copy\", success: \"Copied\", error: \"Couldn’t copy\" };\nconst ACTION = `inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 ${FOCUS}`;\n\n// A GitHub-like palette for light and dark themes.\nconst TONE = {\n  keyword: \"text-[#cf222e] dark:text-[#ff7b72]\",\n  string: \"text-[#0a3069] dark:text-[#a5d6ff]\",\n  comment: \"italic text-[#6e7781] dark:text-[#8b949e]\",\n  number: \"text-[#0550ae] dark:text-[#79c0ff]\",\n  call: \"text-[#8250df] dark:text-[#d2a8ff]\",\n};\n\nconst KEYWORDS =\n  \"const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|import|from|export|default|async|await|new|class|extends|implements|type|interface|enum|true|false|null|undefined|this|try|catch|finally|throw|yield|in|of|typeof|instanceof|as|def|elif|lambda|None|True|False|pass|with|raise|except|and|or|not|is|self|fn|pub|use|mut|impl|struct|match|func|package|go|echo|then|fi|done|esac|local|public|private|protected|static|void|int|string|bool|select|where|insert|update|delete|create|table\";\nconst HASH_COMMENTS = /^(py|python|sh|bash|shell|zsh|yaml|yml|rb|ruby|toml|dockerfile|r|perl)$/i;\n\nfunction tokenize(line: string, hashComments: boolean): React.ReactNode[] {\n  const comment = hashComments ? String.raw`#.*$|\\/\\/.*$` : String.raw`\\/\\/.*$|\\/\\*.*?\\*\\/|<!--.*?-->`;\n  const re = new RegExp(\n    String.raw`(${comment})|(\"(?:[^\"\\\\]|\\\\.)*\"?|'(?:[^'\\\\]|\\\\.)*'?|` + \"`(?:[^`\\\\\\\\]|\\\\\\\\.)*`?\" + String.raw`)|(\\b\\d[\\d_]*(?:\\.\\d+)?\\b)|\\b(${KEYWORDS})\\b|([A-Za-z_$][\\w$]*)(?=\\()`,\n    \"g\"\n  );\n  const out: React.ReactNode[] = [];\n  let last = 0;\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(line))) {\n    if (m.index > last) out.push(line.slice(last, m.index));\n    const tone = m[1] ? TONE.comment : m[2] ? TONE.string : m[3] ? TONE.number : m[4] ? TONE.keyword : TONE.call;\n    out.push(\n      <span key={m.index} className={tone}>\n        {m[0]}\n      </span>\n    );\n    last = m.index + m[0].length;\n    if (m[0].length === 0) re.lastIndex++;\n  }\n  if (last < line.length) out.push(line.slice(last));\n  return out;\n}\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function CodeBlock({\n  code,\n  language,\n  filename,\n  streaming = false,\n  lineNumbers = true,\n  collapseAfter = 18,\n  onApply,\n  applyLabel = \"Apply\",\n  highlight = true,\n  className = \"\",\n  ...props\n}: CodeBlockProps) {\n  const reduced = useReducedMotion();\n  const [copy, setCopy] = React.useState<ActionStatus>(\"idle\");\n  const [wrap, setWrap] = React.useState(false);\n  // Code you watched stream in stays open; only code that arrives complete (e.g. history) starts folded.\n  const [expanded, setExpanded] = React.useState(streaming);\n  const [apply, setApply] = React.useState<ActionStatus>(\"idle\");\n  // New code (a regenerated answer) can be applied again: \"Applied\" or \"Try again\" goes back to Apply.\n  const [appliedCode, setAppliedCode] = React.useState(code);\n  if (appliedCode !== code) {\n    setAppliedCode(code);\n    if (apply === \"success\" || apply === \"error\") setApply(\"idle\");\n  }\n  const [fullHeight, setFullHeight] = React.useState(0);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const wrapFrom = React.useRef<number | null>(null);\n\n  const lines = React.useMemo(() => code.replace(/\\n$/, \"\").split(\"\\n\"), [code]);\n  const hash = HASH_COMMENTS.test(language ?? \"\");\n  const rendered = React.useMemo(\n    () => lines.map((line) => (highlight ? tokenize(line, hash) : [line])),\n    [lines, highlight, hash]\n  );\n\n  const foldable = !streaming && collapseAfter > 0 && lines.length > collapseAfter + 3;\n  const folded = foldable && !expanded;\n  const hidden = lines.length - collapseAfter;\n\n  React.useLayoutEffect(() => {\n    const body = bodyRef.current;\n    if (!body) return;\n    const measure = () => setFullHeight((h) => (h === body.scrollHeight ? h : body.scrollHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(body.firstElementChild as Element);\n    return () => observer.disconnect();\n  }, [wrap]);\n\n  // Wrapping changes how tall the code is: ease the block there instead of jumping. (Folded code has its own transition.)\n  React.useLayoutEffect(() => {\n    const body = bodyRef.current;\n    const from = wrapFrom.current;\n    wrapFrom.current = null;\n    if (!body || from === null || reduced || foldable) return;\n    const to = body.offsetHeight;\n    if (Math.abs(to - from) > 1) body.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: 460, easing: EASE });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [wrap, reduced]);\n\n  // A blocked clipboard says so, then quietly goes back to Copy.\n  React.useEffect(() => {\n    if (copy !== \"error\") return;\n    const timer = window.setTimeout(() => setCopy(\"idle\"), 2400);\n    return () => window.clearTimeout(timer);\n  }, [copy]);\n\n  const copyCode = async () => {\n    try {\n      await navigator.clipboard.writeText(code);\n      setCopy(\"success\");\n    } catch {\n      setCopy(\"error\");\n    }\n  };\n\n  const runApply = async () => {\n    if (!onApply || apply === \"pending\" || apply === \"success\") return;\n    setApply(\"pending\");\n    try {\n      await onApply();\n      setApply(\"success\");\n    } catch {\n      setApply(\"error\");\n    }\n  };\n\n  const gutter = String(lines.length).length;\n\n  return (\n    <div className={`w-full overflow-hidden rounded-[16px] border border-border bg-background ${className}`} {...props}>\n      <div className=\"flex h-10 items-center gap-2 border-b border-border/70 pl-3.5 pr-1.5\">\n        <span className=\"min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted-foreground\">\n          {filename ? (\n            <>\n              <span className=\"text-foreground/80\">{filename}</span>\n              {language && <span className=\"ml-2 opacity-70\">{language}</span>}\n            </>\n          ) : (\n            language ?? \"code\"\n          )}\n        </span>\n        <button\n          type=\"button\"\n          aria-label=\"Wrap lines\"\n          aria-pressed={wrap}\n          onClick={() => {\n            wrapFrom.current = bodyRef.current?.offsetHeight ?? null;\n            setWrap((w) => !w);\n          }}\n          className={`${ACTION} ${wrap ? \"bg-accent text-foreground\" : \"\"} w-7 justify-center px-0`}\n        >\n          <WrapText />\n        </button>\n        <StatusButton\n          variant=\"ghost\"\n          size=\"sm\"\n          icon={<Copy />}\n          status={copy}\n          labels={COPY_LABELS}\n          aria-label=\"Copy code\"\n          onClick={copyCode}\n          onReset={() => setCopy(\"idle\")}\n          resetAfter={1600}\n        />\n        {onApply && (\n          // Waits, faded, until the code has finished arriving.\n          <span className=\"inline-flex transition-opacity duration-300\" style={{ opacity: streaming ? 0.4 : 1 }}>\n            <StatusButton\n              size=\"sm\"\n              // Once applied it settles from a call to action into a quiet receipt.\n              variant={apply === \"success\" ? \"ghost\" : \"primary\"}\n              icon={<CornerDownLeft />}\n              status={apply}\n              labels={{ idle: applyLabel, pending: \"Applying\", success: \"Applied\", error: \"Try again\" }}\n              disabled={streaming}\n              onClick={runApply}\n              className=\"disabled:pointer-events-none\"\n            />\n          </span>\n        )}\n      </div>\n\n      <div className=\"relative\">\n        <div\n          ref={bodyRef}\n          className=\"overflow-hidden\"\n          style={{\n            // Only foldable code gets a height cap (animated between its start and its full height).\n            maxHeight: foldable ? (folded ? PAD + collapseAfter * LINE : fullHeight || undefined) : undefined,\n            transition: reduced ? \"none\" : `max-height 460ms ${EASE}`,\n          }}\n        >\n          <pre\n            className={`py-3 font-mono text-[12.5px] leading-[21px] text-foreground/90 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] ${wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto\"}`}\n            tabIndex={wrap ? undefined : 0}\n            aria-label={`${language ?? \"Code\"}${filename ? `, ${filename}` : \"\"}`}\n          >\n            <code className=\"block min-w-fit\">\n              {rendered.map((tokens, i) => (\n                <span key={i} className=\"flex\">\n                  {lineNumbers && (\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"sticky left-0 shrink-0 select-none bg-background pl-3.5 pr-4 text-right tabular-nums text-muted-foreground/50\"\n                      style={{ width: `calc(${gutter}ch + 2.25rem)` }}\n                    >\n                      {i + 1}\n                    </span>\n                  )}\n                  <span className={`min-w-0 ${lineNumbers ? \"pr-4\" : \"px-4\"} ${wrap ? \"whitespace-pre-wrap break-words\" : \"whitespace-pre\"}`}>\n                    {tokens.length ? tokens : \" \"}\n                    {streaming && i === rendered.length - 1 && (\n                      <span aria-hidden=\"true\" className=\"ml-px inline-block h-[15px] w-[7px] translate-y-[3px] bg-foreground/60 animate-[ui-blink_1s_steps(1)_infinite]\" />\n                    )}\n                  </span>\n                </span>\n              ))}\n            </code>\n          </pre>\n        </div>\n\n        {/* The fade over folded code dissolves as it opens; the bar under it never moves. */}\n        {foldable && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent\"\n            style={{ opacity: folded ? 1 : 0, transition: reduced ? \"none\" : `opacity 360ms ${EASE}` }}\n          />\n        )}\n      </div>\n\n      {foldable && (\n        <div className=\"flex justify-center border-t border-border/60 py-1.5\">\n          <button type=\"button\" onClick={() => setExpanded((e) => !e)} aria-expanded={expanded} className={ACTION}>\n            <ChevronDown className=\"transition-transform duration-300 motion-reduce:transition-none\" style={{ transform: expanded ? \"rotate(180deg)\" : \"none\", transitionTimingFunction: EASE }} />\n            <TextMorph>{expanded ? \"Show less\" : `Show ${hidden} more line${hidden === 1 ? \"\" : \"s\"}`}</TextMorph>\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-blink":{"0%, 100%":{"opacity":"1"},"50%":{"opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/code-block"}