Code block

Code in an answer: highlighted as it streams, copy and wrap, long files folded, and an optional Apply.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/code-block.json

Overview

Code is where answers get used, so the block should read well mid-stream and be easy to take away. A light highlighter is built in, so lines are coloured the moment they arrive, with a caret at the end. The header carries the file name and language, a wrap toggle, and Copy: its icon blurs into a check that draws itself while the label morphs to "Copied". Long files that arrive complete show their start and "Show 42 more lines"; they open smoothly while the fade dissolves and the label becomes "Show less". Pass onApply and Apply runs your action in one motion, Applying with a spinner, then a drawn check as the button settles from blue into a quiet "Applied".

States

streaming
Lines arrive highlighted, with a blinking caret on the last one. Long code you watched stream stays open.
complete
File name and language, a wrap toggle, Copy. Line numbers stay put while you scroll sideways. Toggling wrap eases the block to its new height over 460ms.
copied
The copy icon shrinks away through a 3px blur as a check draws itself in 420ms; "Copy" morphs to "Copied", keeping the letters they share, for 1.6 seconds. A blocked clipboard shakes and says "Couldn’t copy".
folded
Code that arrives complete and runs past collapseAfter shows its start under a fade. "Show 42 more lines" opens it over 460ms while the fade dissolves and the label morphs to "Show less".
apply
Apply → Applying with a spinner while your promise runs → a drawn check and "Applied", as the button eases from your primary colour to a quiet receipt. A rejected promise shakes and offers "Try again". It waits at 40% opacity while code streams, and when the code changes (a regenerated answer) it morphs back to Apply so the new code can go in too.

Usage

import { CodeBlock } from "@/components/code-block";

<CodeBlock code={code} language="ts" filename="lib/queue.ts" streaming={isStreaming} />

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

With the AI SDK

Map the Vercel AI SDK’s message parts and chat status onto the component’s props.

"use client";
import ReactMarkdown from "react-markdown";
import { CodeBlock } from "@/components/code-block";

// Fenced code in the model's markdown becomes a CodeBlock, even mid-stream.
export function Answer({ text, streaming }: { text: string; streaming: boolean }) {
  return (
    <ReactMarkdown
      components={{
        pre: ({ children }) => <>{children}</>,
        code({ className, children }) {
          const language = /language-([\w-]+)/.exec(className ?? "")?.[1];
          if (!language) return <code className="rounded bg-muted px-1 font-mono text-[0.9em]">{children}</code>;
          const code = String(children);
          // Only the block still being written gets the caret.
          return <CodeBlock code={code} language={language} streaming={streaming && text.trimEnd().endsWith(code.trimEnd())} />;
        },
      }}
    >
      {text}
    </ReactMarkdown>
  );
}

// With useChat: the last assistant message is streaming while status === "streaming".

Props

PropTypeDescription
codestringThe code so far, or all of it.
language / filenamestringShown in the header. The language also picks the comment style (// or #).
streamingbooleandefault falseShows the caret and keeps the block open.
lineNumbersbooleandefault trueA sticky line-number gutter.
collapseAfternumberdefault 18Fold complete code longer than this many lines; 0 never folds.
onApply / applyLabel() => void | Promise<void> / stringdefault — / "Apply"Shows Apply, with progress while the promise runs and "Applied" after.
highlightbooleandefault trueTurn the built-in highlighting off, e.g. for logs.

Accessibility and motion

  • Highlighting runs per line with no dependencies, so it’s instant while streaming. For full grammars, render Shiki output yourself and keep this frame.
  • Copy and Wrap are real buttons with labels; Wrap uses aria-pressed. "Copied" and "Applied" are announced from a status region beside each button. The code area is focusable when it scrolls sideways.
  • Copy and Apply are Status buttons and the fold label is a Text morph; the CLI installs both. With reduced motion, states change in place.
  • Line numbers are hidden from screen readers and from copy and paste.
  • The colours follow GitHub’s light and dark palettes; the frame uses your theme’s border and background.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Status button, Text morph.

Source

›Show the full source of code-block.tsx (300 lines)
"use client";

import * as React from "react";
import { ChevronDown, Copy, CornerDownLeft, WrapText } from "lucide-react";
import { StatusButton, type ActionStatus } from "./status-button";
import { TextMorph } from "./text-morph";

/* ─────────────────────────────────────────────────────────
 * CODE BLOCK: code in an answer, as it streams and after
 *
 *   streaming  lines arrive with a caret, already highlighted
 *   complete   language and file name, Copy and wrap; wrapping
 *              eases the block to its new height
 *   copied     the copy icon blurs into a check that draws
 *              itself while "Copy" morphs to "Copied"
 *   folded     long code shows its start and "Show 42 more
 *              lines"; it opens smoothly, the fade dissolves and
 *              the label morphs to "Show less"
 *   apply      optional: Apply → Applying → Applied, one button
 *              that settles into a quiet receipt
 *
 * A light highlighter is built in (keywords, strings, comments,
 * numbers, calls) so it works mid-stream with no dependencies.
 * ───────────────────────────────────────────────────────── */

export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
  code: string;
  /** e.g. "ts", "python", "bash"; shown in the header and used for comments. */
  language?: string;
  filename?: string;
  /** True while the code is still arriving. */
  streaming?: boolean;
  lineNumbers?: boolean;
  /** Fold code longer than this many lines (0 never folds). */
  collapseAfter?: number;
  /** Shows Apply; return a promise to show progress. */
  onApply?: () => void | Promise<void>;
  applyLabel?: string;
  /** Turn the built-in highlighting off, e.g. for plain output. */
  highlight?: boolean;
}

const LINE = 21;
const PAD = 12;
const EASE = "cubic-bezier(0.23,1,0.32,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
const COPY_LABELS = { idle: "Copy", success: "Copied", error: "Couldn’t copy" };
const 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}`;

// A GitHub-like palette for light and dark themes.
const TONE = {
  keyword: "text-[#cf222e] dark:text-[#ff7b72]",
  string: "text-[#0a3069] dark:text-[#a5d6ff]",
  comment: "italic text-[#6e7781] dark:text-[#8b949e]",
  number: "text-[#0550ae] dark:text-[#79c0ff]",
  call: "text-[#8250df] dark:text-[#d2a8ff]",
};

const KEYWORDS =
  "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";
const HASH_COMMENTS = /^(py|python|sh|bash|shell|zsh|yaml|yml|rb|ruby|toml|dockerfile|r|perl)$/i;

function tokenize(line: string, hashComments: boolean): React.ReactNode[] {
  const comment = hashComments ? String.raw`#.*$|\/\/.*$` : String.raw`\/\/.*$|\/\*.*?\*\/|<!--.*?-->`;
  const re = new RegExp(
    String.raw`(${comment})|("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|` + "`(?:[^`\\\\]|\\\\.)*`?" + String.raw`)|(\b\d[\d_]*(?:\.\d+)?\b)|\b(${KEYWORDS})\b|([A-Za-z_$][\w$]*)(?=\()`,
    "g"
  );
  const out: React.ReactNode[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = re.exec(line))) {
    if (m.index > last) out.push(line.slice(last, m.index));
    const tone = m[1] ? TONE.comment : m[2] ? TONE.string : m[3] ? TONE.number : m[4] ? TONE.keyword : TONE.call;
    out.push(
      <span key={m.index} className={tone}>
        {m[0]}
      </span>
    );
    last = m.index + m[0].length;
    if (m[0].length === 0) re.lastIndex++;
  }
  if (last < line.length) out.push(line.slice(last));
  return out;
}

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

export function CodeBlock({
  code,
  language,
  filename,
  streaming = false,
  lineNumbers = true,
  collapseAfter = 18,
  onApply,
  applyLabel = "Apply",
  highlight = true,
  className = "",
  ...props
}: CodeBlockProps) {
  const reduced = useReducedMotion();
  const [copy, setCopy] = React.useState<ActionStatus>("idle");
  const [wrap, setWrap] = React.useState(false);
  // Code you watched stream in stays open; only code that arrives complete (e.g. history) starts folded.
  const [expanded, setExpanded] = React.useState(streaming);
  const [apply, setApply] = React.useState<ActionStatus>("idle");
  // New code (a regenerated answer) can be applied again: "Applied" or "Try again" goes back to Apply.
  const [appliedCode, setAppliedCode] = React.useState(code);
  if (appliedCode !== code) {
    setAppliedCode(code);
    if (apply === "success" || apply === "error") setApply("idle");
  }
  const [fullHeight, setFullHeight] = React.useState(0);
  const bodyRef = React.useRef<HTMLDivElement>(null);
  const wrapFrom = React.useRef<number | null>(null);

  const lines = React.useMemo(() => code.replace(/\n$/, "").split("\n"), [code]);
  const hash = HASH_COMMENTS.test(language ?? "");
  const rendered = React.useMemo(
    () => lines.map((line) => (highlight ? tokenize(line, hash) : [line])),
    [lines, highlight, hash]
  );

  const foldable = !streaming && collapseAfter > 0 && lines.length > collapseAfter + 3;
  const folded = foldable && !expanded;
  const hidden = lines.length - collapseAfter;

  React.useLayoutEffect(() => {
    const body = bodyRef.current;
    if (!body) return;
    const measure = () => setFullHeight((h) => (h === body.scrollHeight ? h : body.scrollHeight));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(body.firstElementChild as Element);
    return () => observer.disconnect();
  }, [wrap]);

  // Wrapping changes how tall the code is: ease the block there instead of jumping. (Folded code has its own transition.)
  React.useLayoutEffect(() => {
    const body = bodyRef.current;
    const from = wrapFrom.current;
    wrapFrom.current = null;
    if (!body || from === null || reduced || foldable) return;
    const to = body.offsetHeight;
    if (Math.abs(to - from) > 1) body.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: 460, easing: EASE });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [wrap, reduced]);

  // A blocked clipboard says so, then quietly goes back to Copy.
  React.useEffect(() => {
    if (copy !== "error") return;
    const timer = window.setTimeout(() => setCopy("idle"), 2400);
    return () => window.clearTimeout(timer);
  }, [copy]);

  const copyCode = async () => {
    try {
      await navigator.clipboard.writeText(code);
      setCopy("success");
    } catch {
      setCopy("error");
    }
  };

  const runApply = async () => {
    if (!onApply || apply === "pending" || apply === "success") return;
    setApply("pending");
    try {
      await onApply();
      setApply("success");
    } catch {
      setApply("error");
    }
  };

  const gutter = String(lines.length).length;

  return (
    <div className={`w-full overflow-hidden rounded-[16px] border border-border bg-background ${className}`} {...props}>
      <div className="flex h-10 items-center gap-2 border-b border-border/70 pl-3.5 pr-1.5">
        <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted-foreground">
          {filename ? (
            <>
              <span className="text-foreground/80">{filename}</span>
              {language && <span className="ml-2 opacity-70">{language}</span>}
            </>
          ) : (
            language ?? "code"
          )}
        </span>
        <button
          type="button"
          aria-label="Wrap lines"
          aria-pressed={wrap}
          onClick={() => {
            wrapFrom.current = bodyRef.current?.offsetHeight ?? null;
            setWrap((w) => !w);
          }}
          className={`${ACTION} ${wrap ? "bg-accent text-foreground" : ""} w-7 justify-center px-0`}
        >
          <WrapText />
        </button>
        <StatusButton
          variant="ghost"
          size="sm"
          icon={<Copy />}
          status={copy}
          labels={COPY_LABELS}
          aria-label="Copy code"
          onClick={copyCode}
          onReset={() => setCopy("idle")}
          resetAfter={1600}
        />
        {onApply && (
          // Waits, faded, until the code has finished arriving.
          <span className="inline-flex transition-opacity duration-300" style={{ opacity: streaming ? 0.4 : 1 }}>
            <StatusButton
              size="sm"
              // Once applied it settles from a call to action into a quiet receipt.
              variant={apply === "success" ? "ghost" : "primary"}
              icon={<CornerDownLeft />}
              status={apply}
              labels={{ idle: applyLabel, pending: "Applying", success: "Applied", error: "Try again" }}
              disabled={streaming}
              onClick={runApply}
              className="disabled:pointer-events-none"
            />
          </span>
        )}
      </div>

      <div className="relative">
        <div
          ref={bodyRef}
          className="overflow-hidden"
          style={{
            // Only foldable code gets a height cap (animated between its start and its full height).
            maxHeight: foldable ? (folded ? PAD + collapseAfter * LINE : fullHeight || undefined) : undefined,
            transition: reduced ? "none" : `max-height 460ms ${EASE}`,
          }}
        >
          <pre
            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"}`}
            tabIndex={wrap ? undefined : 0}
            aria-label={`${language ?? "Code"}${filename ? `, ${filename}` : ""}`}
          >
            <code className="block min-w-fit">
              {rendered.map((tokens, i) => (
                <span key={i} className="flex">
                  {lineNumbers && (
                    <span
                      aria-hidden="true"
                      className="sticky left-0 shrink-0 select-none bg-background pl-3.5 pr-4 text-right tabular-nums text-muted-foreground/50"
                      style={{ width: `calc(${gutter}ch + 2.25rem)` }}
                    >
                      {i + 1}
                    </span>
                  )}
                  <span className={`min-w-0 ${lineNumbers ? "pr-4" : "px-4"} ${wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre"}`}>
                    {tokens.length ? tokens : " "}
                    {streaming && i === rendered.length - 1 && (
                      <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]" />
                    )}
                  </span>
                </span>
              ))}
            </code>
          </pre>
        </div>

        {/* The fade over folded code dissolves as it opens; the bar under it never moves. */}
        {foldable && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent"
            style={{ opacity: folded ? 1 : 0, transition: reduced ? "none" : `opacity 360ms ${EASE}` }}
          />
        )}
      </div>

      {foldable && (
        <div className="flex justify-center border-t border-border/60 py-1.5">
          <button type="button" onClick={() => setExpanded((e) => !e)} aria-expanded={expanded} className={ACTION}>
            <ChevronDown className="transition-transform duration-300 motion-reduce:transition-none" style={{ transform: expanded ? "rotate(180deg)" : "none", transitionTimingFunction: EASE }} />
            <TextMorph>{expanded ? "Show less" : `Show ${hidden} more line${hidden === 1 ? "" : "s"}`}</TextMorph>
          </button>
        </div>
      )}
    </div>
  );
}

Registry item: /r/code-block.json