← All components

CodeBlock

Code, terminal output and diffs, with a copy that hands you what you would actually paste.

lib/renewal.tsts
@@ -12,6 +12,7 @@ export function renewalPrice(plan: Plan) {  const base = plan.seats * plan.rate;Removed:   const discount = plan.annual ? 0.1 : 0;Added:   // Held for one more term for accounts at risk.Added:   const discount = plan.annual ? 0.12 : plan.atRisk ? 0.08 : 0;  return Math.round(base * (1 - discount));}
Terminal
$ pnpm test renewal ✓ renewalPrice holds the annual discount at 12% (3 ms) ✓ renewalPrice gives at-risk monthly accounts 8% (1 ms) Tests  2 passed (2)$ pnpm lint --fix

The idea

It brings no highlighter: most code in a conversation is output that needs none, so it takes plain text or HTML you have already highlighted, and draws the parts that are about reading — header, line numbers, a marked line, the copy. A diff is a + or − in its own gutter and a grey tint, never green and red, because the marker is what survives greyscale and a screen reader. Copy takes a terminal's commands without their prompts, and a diff's file after the change.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

CodeBlock — usage13 lines
import { CodeBlock } from "mihirmodi-ui";

// Plain text: no highlighter shipped.
<CodeBlock title="lib/renewal.ts" language="ts" lineNumbers highlight={[3]} code={source} />

// Or highlight it yourself and pass the HTML; `code` is still what is copied.
<CodeBlock code={source} html={await codeToHtml(source, { lang: "ts", theme })} />

// A diff: +, − or a space leads each line. Copy gives the file after the change.
<CodeBlock variant="diff" title="lib/renewal.ts" code={patch} />

// A terminal: `$ ` lines are commands. Copy gives only the commands.
<CodeBlock variant="terminal" title="Terminal" wrapToggle code={session} />

Props

PropNotes
codestringThe plain text. What is copied, and what is drawn without `html`.
htmlstring | string[]Pre-highlighted lines from your own highlighter. Shiki's `codeToHtml` output works as is.
variant"code" | "terminal" | "diff"`terminal` dims `$ ` prompts; `diff` reads a leading +, − or space.
title / languageReactNode / stringThe header. Without either, the copy button floats at the corner.
lineNumbersbooleanIn a gutter that is never selected or copied.
highlightnumber[]1-based lines to mark with a tint and an ink rule.
wrap / wrapTogglebooleanLong lines scroll sideways unless wrapped; the toggle puts the choice in the header.
copyTextstringOverride what the copy button copies.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/chat/CodeBlock.tsx248 lines
"use client";

import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { CheckIcon } from "../../lib/glyph.js";

export type CodeBlockProps = {
  /** The source, as plain text. Always given: it is what is copied, and what is drawn when there is no `html`. */
  code: string;
  /**
   * Pre-highlighted HTML from your own highlighter, one line per code line —
   * an array, or one string split on newlines (Shiki's `codeToHtml` output
   * works; its `<pre>`/`<code>` wrapper is stripped). For a diff, highlight the
   * lines without their +/− markers; the markers are read from `code`.
   */
  html?: string | string[];
  /**
   * `code` is source. `terminal` dims a leading `$ ` as a prompt and copies only
   * the commands. `diff` reads a leading +, − or space as the change, marks it
   * in a gutter and tints the line.
   */
  variant?: "code" | "terminal" | "diff";
  /** A file name or a label for the header. With neither this nor `language`, the copy button floats instead. */
  title?: ReactNode;
  /** Shown after the title, quieter — "tsx", "bash". */
  language?: string;
  lineNumbers?: boolean;
  /** 1-based line numbers to mark — the lines an answer is talking about. */
  highlight?: number[];
  /** Wrap long lines rather than scroll them. The starting state when `wrapToggle` is on. */
  wrap?: boolean;
  /** A header button that switches wrapping. */
  wrapToggle?: boolean;
  /** Draw the copy button. */
  copyable?: boolean;
  /** What the copy button copies, when it should be something other than the default for the variant. */
  copyText?: string;
  onCopy?: (text: string) => void;
  /** Cap the height; past it the block scrolls. */
  maxHeight?: number;
  className?: string;
};

type Kind = "add" | "del" | "hunk" | "same";
type Line = { text: string; kind: Kind; prompt: boolean };

function parse(code: string, variant: CodeBlockProps["variant"]): Line[] {
  const raw = code.replace(/\n$/, "").split("\n");
  return raw.map((l) => {
    if (variant === "diff") {
      if (l.startsWith("@@")) return { text: l, kind: "hunk", prompt: false };
      if (l.startsWith("+")) return { text: l.slice(1), kind: "add", prompt: false };
      if (l.startsWith("-")) return { text: l.slice(1), kind: "del", prompt: false };
      return { text: l.startsWith(" ") ? l.slice(1) : l, kind: "same", prompt: false };
    }
    if (variant === "terminal" && l.startsWith("$ ")) return { text: l.slice(2), kind: "same", prompt: true };
    return { text: l, kind: "same", prompt: false };
  });
}

function htmlLines(html: string | string[] | undefined) {
  if (html === undefined) return undefined;
  if (Array.isArray(html)) return html;
  const open = html.indexOf("<code");
  const inner = open >= 0 ? html.slice(html.indexOf(">", open) + 1, html.lastIndexOf("</code>")) : html;
  return inner.replace(/\n$/, "").split("\n");
}

/** What a reader wants on their clipboard: the commands without prompts, the file after the change. */
function defaultCopy(lines: Line[], code: string, variant: CodeBlockProps["variant"]) {
  if (variant === "terminal") {
    const cmds = lines.filter((l) => l.prompt);
    return cmds.length ? cmds.map((l) => l.text).join("\n") : code;
  }
  if (variant === "diff") return lines.filter((l) => l.kind === "add" || l.kind === "same").map((l) => l.text).join("\n");
  return code;
}

function WrapGlyph() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="h-[15px] w-[15px]">
      <path d="M4 6.5h16M4 12h12.5a3 3 0 0 1 0 6H12M4 17.5h4.5" />
      <path d="M13.8 15.6L11.5 18l2.3 2.4" />
    </svg>
  );
}

const iconButton =
  "relative flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-quiet transition-[background-color,color,transform] duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000 active:scale-[0.92] motion-reduce:transition-none";

/**
 * Code in an answer: source, a terminal session, or a diff.
 *
 * The block brings no highlighter. Highlighting is a large dependency with
 * opinions about colour, and most code in a conversation is output that needs
 * none; so it takes plain text, or HTML you have already highlighted, and
 * draws the parts that are about reading rather than colouring — the header,
 * line numbers, a marked line, the copy.
 *
 * A diff is marked by a + or − in its own gutter and a grey tint, never by
 * green and red: the marker is what survives greyscale, colour blindness and a
 * screen reader, and the tint only helps the eye group the run. Added lines sit
 * on the stronger tint because they are what stays; removed lines step their
 * text back to a quieter grey.
 *
 * The copy button copies what you would want to paste, not what is drawn: a
 * terminal's commands without their prompts, a diff's file after the change.
 * Its mark blurs into a check and back, the same swap CopyField uses. Long
 * lines scroll sideways by default, because wrapping code changes what it
 * appears to say; wrapping is a toggle for prose-like output.
 */
export function CodeBlock({
  code,
  html,
  variant = "code",
  title,
  language,
  lineNumbers = false,
  highlight,
  wrap: wrapInitial = false,
  wrapToggle = false,
  copyable = true,
  copyText,
  onCopy,
  maxHeight,
  className = "",
}: CodeBlockProps) {
  const lines = useMemo(() => parse(code, variant), [code, variant]);
  const marked = useMemo(() => htmlLines(html), [html]);
  const [wrap, setWrap] = useState(wrapInitial);
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  const header = title !== undefined || language !== undefined || wrapToggle;
  const digits = String(lines.length).length;
  const hl = useMemo(() => new Set(highlight), [highlight]);
  const label = variant === "terminal" ? "Terminal" : undefined;

  const copy = async () => {
    const text = copyText ?? defaultCopy(lines, code, variant);
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      onCopy?.(text);
      window.clearTimeout(timer.current);
      timer.current = window.setTimeout(() => setCopied(false), 1600);
    } catch {
      // Refused — the text is selectable either way.
    }
  };

  const swap = "absolute inset-0 flex items-center justify-center transition-[opacity,filter] motion-reduce:transition-none";
  const copyButton = copyable && (
    <button type="button" onClick={copy} aria-label={copied ? "Copied" : "Copy code"} className={iconButton}>
      <span
        className={swap}
        style={{ opacity: copied ? 0 : 1, filter: copied ? "blur(4px)" : "blur(0px)", transitionDuration: copied ? "180ms" : "280ms", transitionDelay: copied ? "0ms" : "140ms" }}
      >
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="h-[15px] w-[15px]">
          <rect x="8.5" y="8.5" width="11" height="11" rx="2.4" />
          <path d="M15.5 8.5V6.4A2 2 0 0 0 13.5 4.4H6.4A2 2 0 0 0 4.4 6.4v7.1a2 2 0 0 0 2 2h2.1" />
        </svg>
      </span>
      <span
        className={swap}
        style={{ opacity: copied ? 1 : 0, filter: copied ? "blur(0px)" : "blur(4px)", transitionDuration: copied ? "280ms" : "180ms", transitionDelay: copied ? "140ms" : "0ms" }}
      >
        <CheckIcon className="h-[15px] w-[15px]" />
      </span>
    </button>
  );

  return (
    <div className={`group relative overflow-hidden rounded-[12px] border-[0.5px] border-gray-alpha-400 bg-background-200 ${className}`}>
      {header ? (
        <div className="flex h-10 items-center gap-2 border-b-[0.5px] border-gray-alpha-400 pl-3.5 pr-1.5">
          <span className="flex min-w-0 flex-1 items-baseline gap-2">
            <span className="truncate font-mono text-[12px] text-gray-900">{title ?? label}</span>
            {language && <span className="shrink-0 text-[11px] text-faint">{language}</span>}
          </span>
          {wrapToggle && (
            <button type="button" onClick={() => setWrap((w) => !w)} aria-pressed={wrap} aria-label="Wrap long lines" className={`${iconButton} ${wrap ? "bg-gray-alpha-200 text-gray-1000" : ""}`}>
              <WrapGlyph />
            </button>
          )}
          {copyButton}
        </div>
      ) : (
        copyButton && (
          // No header: the button floats at the corner, out of the way until the block is pointed at or focused,
          // and always there on touch, where nothing can be pointed at.
          <div className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity duration-150 ease-out group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
            <div className="rounded-[9px] bg-background-200">{copyButton}</div>
          </div>
        )
      )}
      <span className="sr-only" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>

      <pre
        tabIndex={0}
        dir="ltr"
        className="overflow-auto py-3 font-mono text-[12.5px] leading-[1.65] text-gray-1000"
        style={maxHeight ? { maxHeight } : undefined}
      >
        {/* Inline-block at least as wide as the viewport: every line stretches to the longest, so a tint spans the scroll. */}
        <code className={`block ${wrap ? "" : "w-max min-w-full"}`}>
          {lines.map((l, i) => {
            const n = i + 1;
            const tint =
              l.kind === "add"
                ? "bg-gray-alpha-200"
                : l.kind === "del"
                  ? "bg-gray-alpha-100 text-muted"
                  : l.kind === "hunk"
                    ? "text-faint"
                    : variant === "terminal" && !l.prompt
                      ? "text-muted"
                      : "";
            const mark = hl.has(n) ? "bg-gray-alpha-200 shadow-[inset_2px_0_0_var(--color-ink)]" : "";
            return (
              <span key={i} className={`flex min-h-[1.65em] pr-4 ${tint} ${mark}`}>
                {lineNumbers && (
                  <span aria-hidden className="shrink-0 select-none pl-3.5 pr-3 text-right tabular-nums text-faint" style={{ minWidth: `calc(${digits}ch + 1.75rem)` }}>
                    {n}
                  </span>
                )}
                {variant === "diff" && (
                  <span aria-hidden className={`w-6 shrink-0 select-none text-center ${lineNumbers ? "" : "ml-1.5"} ${l.kind === "same" || l.kind === "hunk" ? "text-transparent" : "text-gray-1000"}`}>
                    {l.kind === "add" ? "+" : l.kind === "del" ? "−" : " "}
                  </span>
                )}
                {/* Heard, never copied. */}
                {l.kind === "add" && <span className="sr-only select-none">Added: </span>}
                {l.kind === "del" && <span className="sr-only select-none">Removed: </span>}
                <span className={`${!lineNumbers && variant !== "diff" ? "pl-3.5" : ""} ${wrap ? "min-w-0 flex-1 whitespace-pre-wrap break-words" : "whitespace-pre"}`}>
                  {l.prompt && <span className="select-none text-faint">$ </span>}
                  {marked?.[i] !== undefined ? <span dangerouslySetInnerHTML={{ __html: marked[i] }} /> : l.text}
                </span>
              </span>
            );
          })}
        </code>
      </pre>
    </div>
  );
}