{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "code-block",
  "type": "registry:component",
  "title": "Code block",
  "description": "Source code with a filename, copy and collapse, or a unified diff of changed lines.",
  "categories": [
    "ai-interface"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/copy-button.json"
  ],
  "files": [
    {
      "path": "src/registry/components/code-block/code-block.tsx",
      "content": "\"use client\";\n\nimport {\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport { ChevronDown, FileCode2 } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { CopyButton } from \"../copy-button/copy-button\";\n\n/** A run of code within a diff row; `change` gives it the word-level add or delete highlight. */\nexport type CodePiece = { text: string; change?: \"add\" | \"del\" };\n/** One row of a unified diff: old and new line numbers, its kind, and its pieces. */\nexport type DiffRow = {\n  old: number | null;\n  cur: number | null;\n  type: \"ctx\" | \"add\" | \"del\";\n  pieces: CodePiece[];\n};\n/** Text on the copy action, before and after copying. */\nexport type CodeBlockLabels = { copy: string; copied: string };\n\nexport interface CodeBlockProps {\n  /** Source shown in the block and copied by the action. */\n  code?: string;\n  /** The source as lines, when it is not one string. Ignored when `code` is set. */\n  lines?: string[];\n  /** Unified-diff rows. When set, the block shows the diff (with old and new line numbers) instead of the plain source. */\n  diff?: DiffRow[];\n  /** File name displayed in the header. */\n  filename?: string;\n  /** Used for the header label and lightweight syntax highlighting. */\n  language?: string;\n  /** Collapse longer sources to this many lines, with a toggle that expands the rest in place. */\n  maxLines?: number;\n  /** Text on the copy action. */\n  labels?: Partial<CodeBlockLabels>;\n  /** Called with the copied text after a successful copy. */\n  onCopy?: (text: string) => void;\n}\n\ntype TokenKind = \"comment\" | \"string\" | \"number\" | \"keyword\" | \"type\" | \"function\" | \"property\" | \"tag\" | \"punctuation\";\n\nconst keywordPattern =\n  /^(?:abstract|as|async|await|break|case|catch|class|const|continue|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|of|private|protected|public|readonly|return|set|static|switch|throw|try|type|typeof|var|void|while|with|yield|SELECT|FROM|WHERE|INSERT|UPDATE|DELETE)$/;\nconst literalPattern = /^(?:true|false|null|undefined|NaN|Infinity)$/;\nconst typePattern =\n  /^(?:Array|Boolean|Date|Error|Map|Number|Promise|Record|Set|String|ReactNode|HTMLElement|HTMLButtonElement|Event|unknown|never|void|any|boolean|number|string|object)$/;\nconst tokenPattern =\n  /\\/\\*[\\s\\S]*?\\*\\/|\\/\\/[^\\n]*|<!--[\\s\\S]*?-->|`(?:\\\\.|[^`\\\\])*`|\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|\\b\\d+(?:\\.\\d+)?\\b|\\b[A-Za-z_$][\\w$-]*\\b|[{}[\\]();,.<>:=+*/!?|&-]/g;\n\n/* Each role has contrast in both themes; color supports, but never replaces, source text. */\nconst tokenClasses: Record<TokenKind, string> = {\n  comment: \"text-ink-3 italic\",\n  string: \"text-[oklch(46%_.13_150)] dark:text-[oklch(77%_.15_150)]\",\n  number: \"text-[oklch(50%_.14_55)] dark:text-[oklch(80%_.14_75)]\",\n  keyword: \"text-[oklch(48%_.15_285)] dark:text-[oklch(78%_.16_285)]\",\n  type: \"text-[oklch(46%_.13_230)] dark:text-[oklch(79%_.13_230)]\",\n  function: \"text-[oklch(43%_.12_25)] dark:text-[oklch(80%_.13_25)]\",\n  property: \"text-[oklch(44%_.12_85)] dark:text-[oklch(79%_.12_85)]\",\n  tag: \"text-[oklch(46%_.13_230)] dark:text-[oklch(79%_.13_230)]\",\n  punctuation: \"text-ink-2\",\n};\n\n/* Deleted rows carry a hatched bar, so the change still reads without colour. */\nconst hatch = \"repeating-linear-gradient(45deg, var(--red) 0, var(--red) 1.5px, transparent 1.5px, transparent 3px)\";\nconst mono = \"[font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace]\";\n\nfunction normaliseLanguage(language: string) {\n  return language.toLowerCase().replace(/^\\./, \"\");\n}\n\nfunction tokenKind(value: string, source: string, index: number, language: string): TokenKind | undefined {\n  if (value.startsWith(\"//\") || value.startsWith(\"/*\") || value.startsWith(\"<!--\")) return \"comment\";\n  if (value.startsWith('\"') || value.startsWith(\"'\") || value.startsWith(\"`\")) {\n    return language === \"json\" && /^\\s*:/.test(source.slice(index + value.length)) ? \"property\" : \"string\";\n  }\n  if (/^\\d/.test(value)) return \"number\";\n  if (keywordPattern.test(value) || literalPattern.test(value)) return \"keyword\";\n  if (typePattern.test(value)) return \"type\";\n  if (/^[{}[\\]();,.<>:=+*/!?|&-]$/.test(value)) return \"punctuation\";\n\n  const before = source.slice(0, index);\n  const after = source.slice(index + value.length);\n  if ((language === \"tsx\" || language === \"jsx\" || language === \"html\" || language === \"vue\") && /<\\/?$/.test(before))\n    return \"tag\";\n  if (/^\\s*\\(/.test(after) && language !== \"json\") return \"function\";\n  if ((language === \"css\" || language === \"tsx\" || language === \"jsx\") && /^\\s*[:=]/.test(after)) return \"property\";\n  return undefined;\n}\n\nfunction highlight(code: string, language: string): ReactNode[] {\n  const parts: ReactNode[] = [];\n  let cursor = 0;\n\n  for (const match of code.matchAll(tokenPattern)) {\n    const value = match[0];\n    const index = match.index ?? 0;\n    if (cursor < index) parts.push(code.slice(cursor, index));\n\n    const kind = tokenKind(value, code, index, language);\n    parts.push(\n      kind ? (\n        <span className={tokenClasses[kind]} key={`${index}-${value}`}>\n          {value}\n        </span>\n      ) : (\n        <Fragment key={`${index}-${value}`}>{value}</Fragment>\n      ),\n    );\n    cursor = index + value.length;\n  }\n\n  if (cursor < code.length) parts.push(code.slice(cursor));\n  return parts;\n}\n\n/** The new side of a diff as plain text: context and added rows, in order. */\nfunction diffSource(rows: DiffRow[]) {\n  return rows\n    .filter((row) => row.type !== \"del\")\n    .map((row) => row.pieces.map((piece) => piece.text).join(\"\"))\n    .join(\"\\n\");\n}\n\n/** A unified diff: one gutter (removals keep the old number, everything else shows the new one), a green or hatched red bar and row tint,\n *  and word-level highlights on the pieces that changed. */\nfunction DiffRows({ rows, language }: { rows: DiffRow[]; language: string }) {\n  return (\n    <span className=\"block w-max min-w-full\">\n      {rows.map((row, index) => {\n        const add = row.type === \"add\",\n          del = row.type === \"del\";\n        return (\n          <span\n            key={index}\n            className={cn(\n              \"relative grid grid-cols-[3rem_minmax(0,1fr)] pr-5\",\n              add && \"bg-green-tint\",\n              del && \"bg-red-tint\",\n            )}\n          >\n            {add || del ? (\n              <span\n                className=\"absolute inset-y-0 left-0 w-[3px]\"\n                style={{ background: add ? \"var(--green)\" : hatch }}\n                aria-hidden=\"true\"\n              />\n            ) : null}\n            <span\n              className={cn(\"pr-4 text-right select-none\", add ? \"text-green\" : del ? \"text-red\" : \"text-ink-3\")}\n              aria-hidden=\"true\"\n            >\n              {(del ? row.old : row.cur) ?? \"\"}\n            </span>\n            <span>\n              {row.pieces.map((piece, at) =>\n                piece.change ? (\n                  <span\n                    key={at}\n                    className={cn(\n                      \"-mx-px rounded-[3px] px-0.5 [box-decoration-break:clone]\",\n                      piece.change === \"add\"\n                        ? \"bg-[color-mix(in_srgb,var(--green)_18%,transparent)]\"\n                        : \"bg-[color-mix(in_srgb,var(--red)_18%,transparent)]\",\n                    )}\n                  >\n                    {highlight(piece.text, language)}\n                  </span>\n                ) : (\n                  <Fragment key={at}>{highlight(piece.text, language)}</Fragment>\n                ),\n              )}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nconst textEnter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;\nconst textExit = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;\n\n/** Text that changes in place: the new words rise in with a soft blur while the old ones leave upward, faster. */\nfunction SwapText({ value, reduced }: { value: string; reduced: boolean }) {\n  return (\n    <AnimatePresence initial={false}>\n      <motion.span\n        key={value}\n        className=\"block overflow-hidden text-ellipsis\"\n        initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={\n          reduced\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: textExit }\n        }\n        transition={reduced ? { duration: 0 } : textEnter}\n      >\n        {value}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\n/* Changing text stacks old and new words in one cell; reserve spans hold the widest label so the cell never snaps. */\nconst swap = \"inline-grid min-w-0 whitespace-nowrap [&>span]:col-start-1 [&>span]:row-start-1 [&>span]:min-w-0\";\nconst swapStack = \"grid min-w-0 [&>span]:col-start-1 [&>span]:row-start-1 [&>span]:min-w-0\";\n\n/** A new file name swaps in while its box follows the new width on a spring, then returns to auto so it can still truncate. */\nfunction FileName({ name, reduced }: { name: string; reduced: boolean }) {\n  const sizer = useRef<HTMLSpanElement>(null);\n  const rest = useRef(0);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = sizer.current;\n    if (!node) return;\n    const observer = new ResizeObserver(() => {\n      rest.current = node.offsetWidth;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  useLayoutEffect(() => {\n    const moving = width.get(),\n      to = sizer.current?.offsetWidth ?? 0;\n    const from = typeof moving === \"number\" ? moving : rest.current;\n    rest.current = to;\n    if (reduced || !from || !to || Math.abs(from - to) < 1) {\n      width.jump(\"auto\");\n      return;\n    }\n    width.jump(from);\n    const controls = animate(width, to, { ...motionTokens.spring.morph, onComplete: () => width.jump(\"auto\") });\n    return () => controls.stop();\n  }, [name, reduced, width]);\n  return (\n    <motion.span className={cn(swap, \"relative overflow-hidden text-ink\")} style={{ width }}>\n      <span\n        ref={sizer}\n        className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\"\n        aria-hidden=\"true\"\n      >\n        {name}\n      </span>\n      <span className={swapStack} aria-hidden=\"true\">\n        <SwapText value={name} reduced={reduced} />\n      </span>\n      <span className=\"sr-only\">{name}</span>\n    </motion.span>\n  );\n}\n\nexport function CodeBlock({ code, lines, diff, filename, language = \"tsx\", maxLines, labels, onCopy }: CodeBlockProps) {\n  const displayLanguage = normaliseLanguage(language);\n  const reduced = useReducedMotion() ?? false;\n  const preId = useId();\n  const source = code ?? (lines ? lines.join(\"\\n\") : diff ? diffSource(diff) : \"\");\n  const lineCount = diff ? diff.length : source.split(\"\\n\").length;\n  const collapsible = maxLines != null && maxLines > 0 && lineCount > maxLines;\n  const [expanded, setExpanded] = useState(false);\n  const open = !collapsible || expanded;\n  const preRef = useRef<HTMLPreElement>(null);\n  const measured = useRef<{ full: number; collapsed: number } | null>(null);\n  // Before the first measurement the clip height comes from CSS, so the server render is already collapsed.\n  const height = useMotionValue<number | string>(open ? \"auto\" : \"var(--code-collapsed)\");\n  const target = useRef({ open, reduced });\n  const settleRef = useRef<(instant: boolean) => void>(() => {});\n  const added = diff?.filter((row) => row.type === \"add\").length ?? 0;\n  const removed = diff?.filter((row) => row.type === \"del\").length ?? 0;\n\n  // The source area follows its content on a spring: expanding, collapsing, and new code all resize smoothly.\n  useLayoutEffect(() => {\n    const pre = preRef.current;\n    if (!pre) return;\n    const settle = (instant: boolean) => {\n      const sizes = measured.current;\n      if (!sizes) return;\n      const next = target.current.open ? sizes.full : sizes.collapsed;\n      if (instant || target.current.reduced || typeof height.get() !== \"number\") height.jump(next);\n      else animate(height, next, motionTokens.spring.smooth);\n    };\n    const observer = new ResizeObserver(() => {\n      const style = getComputedStyle(pre);\n      const full = pre.offsetHeight;\n      const collapsed = maxLines\n        ? Math.min(\n            full,\n            Math.round(\n              maxLines * parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom),\n            ),\n          )\n        : full;\n      const first = !measured.current;\n      measured.current = { full, collapsed };\n      settle(first);\n    });\n    observer.observe(pre);\n    settleRef.current = settle;\n    return () => observer.disconnect();\n  }, [height, maxLines]);\n  useLayoutEffect(() => {\n    target.current = { open, reduced };\n    settleRef.current(false);\n  }, [open, reduced]);\n  const expandLabels = [`Show all ${lineCount} lines`, \"Show fewer lines\"] as const;\n  const contentKey = diff ? JSON.stringify(diff) : source;\n\n  return (\n    <section\n      className=\"w-full max-w-full min-w-0 overflow-hidden rounded-panel border border-line bg-surface\"\n      aria-label={filename ? `${filename} source code` : `${displayLanguage} source code`}\n      style={maxLines ? ({ \"--code-lines\": maxLines } as CSSProperties) : undefined}\n    >\n      {/* On narrow screens the copy action collapses to its icon. */}\n      <header className=\"flex min-h-[52px] items-center justify-between gap-3 border-b border-line-soft bg-surface py-2 pr-3 pl-4 max-[420px]:pl-3 max-[420px]:[&_button]:w-control-sm max-[420px]:[&_button]:px-0 max-[420px]:[&_button>span:last-of-type]:hidden\">\n        {/* Baseline alignment keeps the smaller mono language label on the file name's baseline; the icon centres on its own. */}\n        <div className=\"flex min-w-0 items-baseline gap-2 font-body text-sm leading-body font-medium tracking-body text-ink-2 [&>svg]:flex-none [&>svg]:self-center [&>svg]:text-ink-3\">\n          <FileCode2 size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          <FileName name={filename ?? \"Source code\"} reduced={reduced} />\n          <span\n            className={cn(\n              mono,\n              \"flex-none text-xs/[inherit] font-normal text-ink-3 before:mr-2 before:text-line-strong before:content-['·']\",\n            )}\n          >\n            {displayLanguage}\n          </span>\n        </div>\n        <div className=\"flex flex-none items-center gap-3\">\n          {diff ? (\n            <span\n              className={cn(mono, \"inline-flex items-center gap-2 text-xs tabular-nums\")}\n              aria-label={`${added} added, ${removed} removed`}\n            >\n              <span className=\"text-green\" aria-hidden=\"true\">\n                +{added}\n              </span>\n              <span className=\"text-red\" aria-hidden=\"true\">\n                −{removed}\n              </span>\n            </span>\n          ) : null}\n          <CopyButton\n            value={source}\n            label={labels?.copy ?? \"Copy code\"}\n            copiedLabel={labels?.copied}\n            onCopied={onCopy ? () => onCopy(source) : undefined}\n          />\n        </div>\n      </header>\n      {/* The source area follows its content height on a spring. Before it measures, a collapsed block clips from CSS. */}\n      <motion.div\n        className=\"overflow-hidden [--code-collapsed:calc(var(--code-lines,0)_*_var(--text-sm)_*_1.7_+_var(--space-5)_*_2)] max-[420px]:[--code-collapsed:calc(var(--code-lines,0)_*_var(--text-xs)_*_1.7_+_var(--space-4)_*_2)]\"\n        style={{ height }}\n      >\n        <pre\n          ref={preRef}\n          id={preId}\n          className={cn(\n            \"relative m-0 overflow-auto p-5 text-sm/[1.7] whitespace-pre text-ink [font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,Liberation_Mono,monospace] [font-variant-ligatures:none] [tab-size:2] max-[420px]:p-4 max-[420px]:text-xs/[1.7]\",\n            diff && \"px-0 max-[420px]:px-0\",\n          )}\n          // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- long lines scroll sideways, so keyboard users focus the source to scroll it\n          tabIndex={0}\n          aria-label=\"Selectable source code\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {/* Block so new source can rise in with a transform; inline code ignores transforms. */}\n            <motion.code\n              key={contentKey}\n              className=\"block\"\n              initial={reduced ? false : { opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: textExit }}\n              transition={reduced ? { duration: 0 } : textEnter}\n            >\n              {diff ? <DiffRows rows={diff} language={displayLanguage} /> : highlight(source, displayLanguage)}\n            </motion.code>\n          </AnimatePresence>\n        </pre>\n      </motion.div>\n      {collapsible && (\n        <button\n          type=\"button\"\n          className=\"group/expand flex min-h-11 w-full cursor-pointer items-center justify-center gap-2 border-t border-line-soft bg-surface px-4 text-sm/[inherit] font-medium text-ink-2 transition-[color,background-color] duration-fast ease-standard hover:bg-hover hover:text-ink motion-reduce:transition-none\"\n          aria-expanded={expanded}\n          aria-controls={preId}\n          onClick={() => setExpanded((value) => !value)}\n        >\n          {/* Both labels reserve the cell, so the chevron never moves when the words change. */}\n          <span className={swap} aria-hidden=\"true\">\n            {expandLabels.map((text) => (\n              <span key={text} className=\"invisible\">\n                {text}\n              </span>\n            ))}\n            <span className={swapStack}>\n              <SwapText value={expandLabels[expanded ? 1 : 0]} reduced={reduced} />\n            </span>\n          </span>\n          <span className=\"sr-only\">{expandLabels[expanded ? 1 : 0]}</span>\n          <motion.span\n            className=\"grid flex-none place-items-center text-ink-3 group-hover/expand:text-ink\"\n            aria-hidden=\"true\"\n            initial={false}\n            animate={{ rotate: expanded ? 180 : 0 }}\n            transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n          >\n            <ChevronDown size={16} strokeWidth={1.75} />\n          </motion.span>\n        </button>\n      )}\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/code-block/code-block.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/code-block",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "CodeBlock",
    "docs": "https://irisui.dev/components/code-block"
  }
}
