CodeBlock
Code, terminal output and diffs, with a copy that hands you what you would actually paste.
@@ -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));}$ 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 --fixThe 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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| codestring | string | The plain text. What is copied, and what is drawn without `html`. |
| htmlstring | string[] | string | string[] | Pre-highlighted lines from your own highlighter. Shiki's `codeToHtml` output works as is. |
| variant"code" | "terminal" | "diff" | "code" | "terminal" | "diff"= "code" | `terminal` dims `$ ` prompts; `diff` reads a leading +, − or space. |
| title / languageReactNode / string | ReactNode / string | The header. Without either, the copy button floats at the corner. |
| lineNumbersboolean | boolean= false | In a gutter that is never selected or copied. |
| highlightnumber[] | number[] | 1-based lines to mark with a tint and an ink rule. |
| wrap / wrapToggleboolean | boolean= false | Long lines scroll sideways unless wrapped; the toggle puts the choice in the header. |
| copyTextstring | string | Override 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.
"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>
);
}