Developer
Code block
Frames a block of code with a filename, line numbers, highlighted or diff lines, copy and an optional fold.
- Built on
<figure><pre>Async Clipboard APIinterpolate-size- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.3 kB
- Built from
- Button
const res = await fetch(
"/v1/deploys",
{ method: "POST" },
);
Code
import { CodeBlock } from "@/components/equip/code-block";
const code = `const res = await fetch(
"/v1/deploys",
{ method: "POST" },
);`;
export default function Demo() {
return (
<div style={{ width: 224 }}>
<CodeBlock code={code} filename="deploy.ts" language="ts" highlight={[3]} />
</div>
);
}Examples
Diff with line numbers
Added and removed lines carry a plus or a minus as well as a tint, and screen readers hear which is which.
export const config = {
region: "eu-west-1",
region: "eu-central-1",
retries: 3,
timeoutMs: 10_000,
};
Code
import { CodeBlock } from "@/components/equip/code-block";
const code = `export const config = {
region: "eu-west-1",
region: "eu-central-1",
retries: 3,
timeoutMs: 10_000,
};`;
export default function Example() {
return (
<CodeBlock
code={code}
filename="equip.config.ts"
language="ts"
lineNumbers
removed={[2]}
added={[3, 5]}
style={{ maxWidth: 420 }}
/>
);
}Collapsed long file
Copy always takes the whole file, even while most of it is folded away.
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm build
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: pnpm dlx equip deploy --prod
Code
import { CodeBlock } from "@/components/equip/code-block";
const code = `name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
concurrency:
group: deploy-\${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm build
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: pnpm dlx equip deploy --prod`;
export default function Example() {
return (
<CodeBlock code={code} filename=".github/workflows/deploy.yml" language="yaml" lineNumbers maxHeight={180} style={{ maxWidth: 420 }} />
);
}Soft wrap without a header
With no filename or language the copy button sits over the code, and long lines wrap instead of scrolling.
curl -X POST https://api.example.com/v1/deploys -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" -d '{"project":"web","ref":"main"}'
Code
import { CodeBlock } from "@/components/equip/code-block";
const code = `curl -X POST https://api.example.com/v1/deploys -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" -d '{"project":"web","ref":"main"}'`;
export default function Example() {
return <CodeBlock code={code} wrap style={{ maxWidth: 380 }} />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
code | string | none | The source text. Always what the copy button copies. |
filename | string | none | Shown in the header and used to name the scroll region. |
language | string | none | A quiet label in the header. Nothing is parsed or coloured. |
lineNumbers | boolean | false | Shows line numbers, which are never selected or copied. |
highlight | number[] | [] | 1-based lines to emphasise with a tint and a start-edge bar. |
added | number[] | [] | 1-based lines marked with a plus and an ok tint. |
removed | number[] | [] | 1-based lines marked with a minus and a danger tint. |
wrap | boolean | false | Soft-wraps long lines instead of scrolling sideways. |
maxHeight | number | none | Folds taller code to this many pixels, with a button that shows every line. |
copy | boolean | true | Shows a copy button. |
children | ReactNode[] | none | Pre-highlighted nodes, one per line, drawn instead of the plain lines of code. |
...rest | ComponentProps<"figure"> | none | Every native figure attribute. |
Anatomy
| root | .eq-code-block | A figure. |
|---|---|---|
| header | .eq-code-block-header | The figcaption, present with a filename or language. |
| filename | .eq-code-block-filename | |
| language | .eq-code-block-language | |
| copy | .eq-code-block-copy | Floats over the code when there is no header. |
| copy label | .eq-code-block-copy-label | Copy, then Copied with a drawn tick. |
| scroll | .eq-code-block-scroll | The focusable, labelled region that scrolls. |
| pre | .eq-code-block-pre | |
| code | .eq-code-block-code | |
| line | .eq-code-block-line | Its line number is generated content from data-line. |
| diff mark | .eq-code-block-mark | Plus or minus, drawn by CSS. |
| line text | .eq-code-block-text | |
| footer | .eq-code-block-footer | Holds the show all lines button when folded. |
| status | .eq-code-block-sr | Announces Copied. |
Keyboard
| Tab | Moves to the copy button, the code region and the show all lines button. |
|---|---|
| Arrow keys | Scroll the focused code region. |
| EnterSpace | Copies the code, or folds and unfolds it. |
Notes
- Line numbers and diff marks are generated content, so selecting the code never picks them up.
- Added and removed lines carry a plus or minus as well as a tint, and screen readers hear Added or Removed before the line.
- The copy button always copies code in full, even while the block is folded or children draw the lines.
- It parses nothing: pass highlighted nodes as children if you run a highlighter yourself.
- The server cannot measure, so a block with maxHeight renders folded and drops the button after mount if everything fits.
- The file is a client component only for copying and folding; everything else renders on the server.
- Unfolding animates its height where interpolate-size is supported (Chromium); elsewhere it opens at once. Copy falls back to a temporary textarea and execCommand where the async clipboard is unavailable.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/code-block.tsx212 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import { Button } from "./button";
import "./code-block.css";
export type CodeBlockProps = Omit<ComponentProps<"figure">, "children"> & {
/** The source text. Always what the copy button copies, even when `children` draws the lines. */
code: string;
/** Shown in the header and used to name the scroll region. */
filename?: string;
/** A quiet label in the header, such as "ts" or "bash". Nothing is parsed. */
language?: string;
lineNumbers?: boolean;
/** 1-based line numbers to emphasise. */
highlight?: number[];
/** 1-based line numbers shown as added, with a plus. */
added?: number[];
/** 1-based line numbers shown as removed, with a minus. */
removed?: number[];
/** Soft-wraps long lines instead of scrolling sideways. */
wrap?: boolean;
/** Collapses taller code to this height in pixels, with a button that shows the rest. */
maxHeight?: number;
/** Shows a copy button. */
copy?: boolean;
/** Pre-highlighted nodes, one per line, drawn instead of the plain lines of `code`. */
children?: ReactNode[];
};
const COPIED_MS = 1600;
type CopyState = "idle" | "copied" | "failed";
/** Last resort for browsers without the async clipboard: a throwaway textarea inside the part. */
function legacyCopy(text: string, host: HTMLElement) {
const active = document.activeElement;
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.setAttribute("aria-hidden", "true");
area.style.position = "fixed";
area.style.inset = "0 auto auto 0";
area.style.opacity = "0";
area.style.pointerEvents = "none";
host.appendChild(area);
area.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
host.removeChild(area);
if (active instanceof HTMLElement) active.focus();
return ok;
}
export function CodeBlock({
code,
filename,
language,
lineNumbers = false,
highlight = [],
added = [],
removed = [],
wrap = false,
maxHeight,
copy = true,
children,
className,
style,
...rest
}: CodeBlockProps) {
const id = useId();
const rootRef = useRef<HTMLElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState<CopyState>("idle");
const [expanded, setExpanded] = useState(false);
const [fits, setFits] = useState(false);
const rows: ReactNode[] = children ?? code.replace(/\r?\n$/, "").split(/\r?\n/);
const count = rows.length;
const diff = added.length > 0 || removed.length > 0;
const collapsible = maxHeight != null && !fits;
const collapsed = collapsible && !expanded;
const hasHeader = filename != null || language != null;
// The server cannot measure, so it assumes the code is taller than maxHeight; drop the button if not.
useEffect(() => {
const region = scrollRef.current;
if (maxHeight == null || !region) return;
setFits(region.scrollHeight <= maxHeight + 1);
}, [maxHeight, code, children]);
useEffect(() => {
if (copied === "idle") return;
const t = window.setTimeout(() => setCopied("idle"), COPIED_MS);
return () => window.clearTimeout(t);
}, [copied]);
async function handleCopy() {
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code);
ok = true;
}
} catch {
ok = false;
}
if (!ok && rootRef.current) ok = legacyCopy(code, rootRef.current);
setCopied(ok ? "copied" : "failed");
}
const copyButton = copy && (
<button
type="button"
className="eq-code-block-copy"
data-state={copied}
aria-label={filename ? `Copy ${filename}` : "Copy code"}
onClick={handleCopy}
>
{copied === "copied" ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3.5 8.5l3 3 6-7" pathLength={1} />
</svg>
) : (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
<path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
</svg>
)}
<span className="eq-code-block-copy-label" aria-hidden="true">
{copied === "copied" ? "Copied" : copied === "failed" ? "Failed" : "Copy"}
</span>
</button>
);
return (
<figure
{...rest}
ref={rootRef}
className={["eq-code-block", className].filter(Boolean).join(" ")}
style={{ "--_digits": String(count).length, ...(maxHeight != null && { "--_max": `${maxHeight}px` }), ...style } as CSSProperties}
data-numbers={lineNumbers || undefined}
data-diff={diff || undefined}
data-wrap={wrap || undefined}
data-bare={!hasHeader || undefined}
>
{hasHeader ? (
<figcaption className="eq-code-block-header">
{filename != null && <span className="eq-code-block-filename">{filename}</span>}
{language != null && <span className="eq-code-block-language">{language}</span>}
{copyButton}
</figcaption>
) : (
copyButton
)}
<div
ref={scrollRef}
id={`${id}-code`}
className="eq-code-block-scroll"
role="region"
tabIndex={0}
aria-label={filename ?? (language ? `${language} code` : "Code")}
data-collapsed={collapsed || undefined}
>
<pre className="eq-code-block-pre">
<code className="eq-code-block-code">
{rows.map((row, i) => {
const n = i + 1;
const change = added.includes(n) ? "added" : removed.includes(n) ? "removed" : undefined;
return (
<span
key={n}
className="eq-code-block-line"
data-line={n}
data-change={change}
data-highlight={highlight.includes(n) || undefined}
>
{diff && <span className="eq-code-block-mark" />}
<span className="eq-code-block-text">
{row}
{"\n"}
</span>
</span>
);
})}
</code>
</pre>
</div>
{collapsible && (
<div className="eq-code-block-footer">
<Button
variant="ghost"
size="s"
aria-expanded={expanded}
aria-controls={`${id}-code`}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? "Show fewer lines" : `Show all ${count} lines`}
</Button>
</div>
)}
<span className="eq-code-block-sr" role="status">
{copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : ""}
</span>
</figure>
);
}components/equip/code-block.css285 lines
@layer equip {
.eq-code-block {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
margin: 0;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
color: var(--eq-ink);
}
.eq-code-block-header {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-3) 2px;
border-bottom: 1px solid var(--eq-line);
}
.eq-code-block-filename {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--eq-ink);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight);
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.eq-code-block-language {
flex: none;
color: var(--eq-ink-3);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight);
line-height: 1;
}
.eq-code-block-copy {
display: inline-flex;
flex: none;
align-items: center;
gap: var(--eq-space-1);
height: 24px;
margin: 0 0 0 auto;
padding-inline: var(--eq-space-2);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-code-block-copy > svg {
width: 14px;
height: 14px;
}
/* Without a header the button sits over the top end of the code. */
.eq-code-block[data-bare] > .eq-code-block-copy {
position: absolute;
z-index: 1;
inset-block-start: var(--eq-space-2);
inset-inline-end: var(--eq-space-2);
border: 1px solid var(--eq-line);
background: var(--eq-raised);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
@media (hover: hover) {
.eq-code-block-copy:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
.eq-code-block[data-bare] > .eq-code-block-copy:hover {
background: color-mix(in srgb, var(--eq-raised) 90%, var(--eq-ink));
}
}
.eq-code-block-copy:active {
scale: var(--eq-press);
}
.eq-code-block-copy:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-code-block-copy[data-state="copied"] {
color: var(--eq-ok);
}
.eq-code-block-copy[data-state="failed"] {
color: var(--eq-danger);
}
/* Copied: the tick draws itself in. */
.eq-code-block-copy[data-state="copied"] path {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
}
@starting-style {
.eq-code-block-copy[data-state="copied"] path {
stroke-dashoffset: 1;
}
}
/* The region clips its own tints to the frame's corners, so buttons keep room for their focus rings. */
.eq-code-block-scroll {
--_r: calc(var(--eq-radius-l) - 1px);
min-width: 0;
overflow: auto;
}
.eq-code-block[data-bare] .eq-code-block-scroll {
border-start-start-radius: var(--_r);
border-start-end-radius: var(--_r);
}
.eq-code-block:not(:has(> .eq-code-block-footer)) .eq-code-block-scroll {
border-end-start-radius: var(--_r);
border-end-end-radius: var(--_r);
}
/* Inset, so the ring sits inside the frame's border rather than on it. */
.eq-code-block-scroll:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-code-block-scroll[data-collapsed] {
block-size: var(--_max);
overflow-y: hidden;
mask-image: linear-gradient(to bottom, var(--eq-ink) calc(100% - var(--eq-space-6)), transparent);
}
/* Expanding grows the height where the engine can size to auto. Elsewhere it is instant. */
@supports (interpolate-size: allow-keywords) {
.eq-code-block {
interpolate-size: allow-keywords;
}
.eq-code-block-scroll {
transition: block-size var(--eq-dur-move) var(--eq-ease);
}
.eq-code-block-scroll[data-collapsed] {
transition-duration: var(--eq-dur);
}
}
.eq-code-block-pre {
margin: 0;
padding-block: calc(var(--eq-space-2) - 1px);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight);
line-height: 1.5;
tab-size: 2;
}
/* As wide as the longest line, so every tint spans the full scroll width. */
.eq-code-block-code {
display: block;
width: max-content;
min-width: 100%;
font: inherit;
}
.eq-code-block[data-wrap] .eq-code-block-code {
width: auto;
}
.eq-code-block-line {
display: flex;
min-height: 1lh;
padding-inline: calc(var(--eq-space-3) - 2px) var(--eq-space-4);
border-inline-start: 2px solid transparent;
}
.eq-code-block[data-bare]:has(> .eq-code-block-copy) .eq-code-block-line:first-child {
padding-inline-end: calc(var(--eq-space-6) + var(--eq-space-6));
}
.eq-code-block-line[data-highlight] {
border-inline-start-color: var(--eq-ink);
background: var(--eq-tint-strong);
}
.eq-code-block-line[data-change="added"] {
background: color-mix(in srgb, var(--eq-ok) 13%, transparent);
}
.eq-code-block-line[data-change="removed"] {
background: color-mix(in srgb, var(--eq-danger) 13%, transparent);
}
/* Generated content never enters a selection or the clipboard. */
.eq-code-block[data-numbers] .eq-code-block-line::before {
content: attr(data-line);
content: attr(data-line) / "";
flex: none;
width: calc(var(--_digits) * 1ch);
margin-inline-end: var(--eq-space-3);
color: var(--eq-ink-3);
text-align: end;
user-select: none;
-webkit-user-select: none;
}
/* On a tinted line --eq-ink-3 drops under 4.5:1, so the number steps up a tone. */
.eq-code-block[data-numbers] .eq-code-block-line:is([data-highlight], [data-change])::before {
color: var(--eq-ink-2);
}
.eq-code-block-mark {
flex: none;
width: 2ch;
user-select: none;
-webkit-user-select: none;
}
.eq-code-block-line[data-change="added"] > .eq-code-block-mark::before {
content: "+";
content: "+" / "Added: ";
color: var(--eq-ok);
}
.eq-code-block-line[data-change="removed"] > .eq-code-block-mark::before {
content: "-";
content: "-" / "Removed: ";
color: var(--eq-danger);
}
.eq-code-block-text {
flex: 1 0 auto;
}
.eq-code-block[data-wrap] .eq-code-block-text {
flex: 1 1 auto;
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.eq-code-block-footer {
display: flex;
justify-content: center;
padding: var(--eq-space-1);
border-top: 1px solid var(--eq-line);
}
/* Read by screen readers, never seen. */
.eq-code-block-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}