Code Snippet
Read-only code with a copy affordance that confirms it copied. Inline, single-line and multi-line — plus the copy button everywhere else a value has to be transcribed.
Also called Copy to Clipboard, Copy Button, Code Block, Pre — in this system all of them are Code Snippet.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
1import { createClient } from '@acme/sdk'23const client = createClient({4 apiKey: process.env.ACME_API_KEY,5 region: 'eu-west-2',6})78const deployment = await client.deployments.create({9 project: 'api-gateway',10 ref: 'main',11})The three containers
Inline for a term in a sentence, single-line for a value to take away, multi-line for something to run. All three copy the exact characters and nothing else.
Secrets
Masked by default, revealable, and copyable without revealing. Forcing a reveal before copy shows the key to everyone behind the user for no security benefit at all.
The prompt must not be copied
A leading $ tells the reader this is a shell command. Pasting it into a shell produces "command not found:
quot;. Render it as an aria-hidden, unselectable prefix.Copy outside a code block
The same affordance, the same confirmation. A support reference, a share link, a resource ID — all values a user would otherwise transcribe.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
--dry-runacme deploy••••••••••••••••••npx @acme/cli deploy --project api-gateway --ref mainEvery part, every measurement, and the reason it is that number.
1const client = createClient({2 region: 'eu-west-2',3})A multi-line block: language tag, line numbers, syntax colours, and the copy control in the corner nearest the reader’s exit.
- Type13px / 1.55 monospace
One step below body copy — monospace runs optically larger at the same nominal size. The 1.55 leading is looser than prose because code is scanned vertically as columns.
- Surface--ds-surface-inset
A well, not a raised card. Code is content set into the page, and the inset reads as "this is a different kind of text" without needing a heavy border.
- Padding14px, 12px with numbers
Reduced on the left when line numbers are present, since the gutter already provides the inset.
- Line numbers11px, disabled tone, user-select: none
Deliberately unselectable. Numbers landing on the clipboard is the single most common bug in this component.
- Copy control28px, top-right, 8px inset
Top-right because that is where the eye leaves a left-aligned block. Always visible on touch; may fade in on hover on fine pointers, never on touch where there is no hover.
- ConfirmationTick for 1.6s + aria-live
Long enough to be noticed after the eye returns from the paste target, short enough that a second copy still reads as a new event.
- Max height24rem, then scrolls
About 24 lines. Past that the block dominates the page and the reader has lost the context it was illustrating.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Block and inline background |
| --ds-border-subtle | — | Block edge and inline outline |
| --ds-fg | — | Default code text |
| --ds-fg-muted | — | Comments, and the idle copy glyph |
| --ds-fg-disabled | — | Line-number gutter and the shell prompt — both non-content |
| --ds-success-text | — | The copied confirmation |
| --ds-accent-text | — | Keywords in the highlight theme |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Block padding | |
| --space-2 | Copy-control inset from the corner |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Block corners | |
| --radius-xs | Inline snippet corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | All code, at every size |
Motion
| Token | Value | Used for |
|---|---|---|
| confirm hold | How long the tick stays |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Inline | Line height | 2px 6px | 4px | 0.9em | — | — | Inside a sentence. Sized relative to the surrounding text so it never breaks the line rhythm. |
| Single line | 36px | 0 4px 0 10px | 8px | 13px | — | — | A value to take away: a command, a key, an ID. Scrolls horizontally rather than wrapping. |
| Multi-line | Max 24rem, then scrolls | 14px | 12px | 13px | — | — | Something to run or paste. About 24 lines before the block starts dominating the page. |
| Copy control | 28px | — | — | — | — | 44px on coarse pointers | Fixed size at every container size — it is a target, not a decoration. |
| Gutter | — | — | — | 11px | 2.5rem | — | Right-aligned numbers, unselectable, never part of the copied string. |
copy(props.code)
✗ copy(el.innerText)role="status" → “Copied”••••••••••••••••••••••dpl_7Hq3nR8vTx$ npm install @acme/sdk → clipboardOur revolutionary new platform is blazing fast.
npx @acme/cli deploy --project api-gateway --ref main --region eu-west-2line 1 of 300…
line 2 of 300…
line 3 of 300…
line 4 of 300…
line 5 of 300…
line 6 of 300…
line 7 of 300…
line 8 of 300…
line 9 of 300…
line 10 of 300…
line 11 of 300…
line 12 of 300…Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Every syntax colour must reach 4.5:1 against the inset surface, in both themes. Highlight themes ported from an editor almost never do — comments are the usual failure.
- Comments are the one token allowed to sit at the bottom of the range, and they still owe 4.5:1. They are content, not chrome.
- The line-number gutter is non-content and may use the disabled tone. Nothing a user needs to read may.
- The copied tick must not be the only signal of success — colour alone fails for the 8% who cannot distinguish it from the idle glyph.
Keyboard
| Tab | Reaches the copy control. It is a real button, never a click handler on a div. |
| Enter / Space | Copies and fires the confirmation. |
| Tab | Reaches a scrollable block itself, which must carry tabindex="0" so a keyboard user can scroll it. |
| ← / → / Home / End | Scrolls a focused overflowing block horizontally. |
| ⌘A / ⌃A | Inside a focused block, selects the code — and must select the code only, not the gutter. |
Screen readers
- Announce the language and line count before the code: "TypeScript, 9 lines". Reading nine lines of punctuation with no warning is disorienting.
- The copy confirmation must be announced. "Copied" from a polite live region is enough; do not use assertive, which interrupts.
- A masked secret should announce as masked — "API key, hidden" — so a screen-reader user knows the reveal control exists and why.
Focus & touch
- The copy control keeps its focus ring after activation — a control that visually changes on success must not also appear to lose focus. Focus never moves on copy; the user stays exactly where they were.
- The copy control is 44px on coarse pointers and always visible — hover-revealed controls do not exist on touch, and hand-selecting a 40-character key on a phone is the worst interaction in any developer product. Blocks scroll horizontally with momentum rather than wrapping, and the block itself must not swallow the page’s vertical scroll.
| Attribute | Applied to | Notes |
|---|---|---|
| role="status" | A visually hidden live region | Announces "Copied". Without it the confirmation is purely visual and the interaction has no feedback at all for a screen-reader user. |
| aria-label | The copy button | Names the value: "Copy API key", not "Copy". A page with six copy buttons otherwise has six identical controls. |
| tabindex="0" | A scrollable block | Required by 2.1.1 — a region that scrolls must be reachable by keyboard. |
| aria-label | The scrollable block | Names what the code is, so the region announces as "Deployment example, code" rather than as an anonymous scroll area. |
| user-select: none | The gutter and the prompt | Not ARIA, but the same intent: these characters are not content and must never reach a selection or the clipboard. |
Example usage
1import { CodeSnippet, CopyButton } from '@/ui/Code'23<CodeSnippet lang="bash" prompt="quot;>4 npx @acme/cli deploy --project api-gateway5</CodeSnippet>67// The copy affordance is the component. It works anywhere a value has to8// be transcribed — this is why there is no separate "Copy to Clipboard".9<CopyButton value={deployment.id} label="Copy deployment ID" />1011// Copy from the source string. Never from the DOM: innerText drags in line12// numbers, the prompt, and the highlighter's markup.13function useCopy(timeout = 1600) {14 const [copied, setCopied] = React.useState(false)15 const copy = async (text: string) => {16 try {17 await navigator.clipboard.writeText(text)18 } catch {19 legacyCopy(text) // http:// and older Safari have no clipboard API20 }21 setCopied(true)22 setTimeout(() => setCopied(false), timeout)23 }24 return { copied, copy }25}Framework-free HTML
<!-- Multi-line. The block is focusable because it scrolls. -->
<figure class="ds-code">
<figcaption class="sr-only">Deployment example, TypeScript, 9 lines</figcaption>
<pre tabindex="0" aria-label="Deployment example"><code class="language-ts"
><span class="ds-code__gutter" aria-hidden="true">1</span>const client = createClient({}</code></pre>
<button type="button" class="ds-code__copy" aria-label="Copy code">
<svg aria-hidden="true">…</svg>
</button>
</figure>
<!-- The confirmation everyone forgets -->
<p class="sr-only" role="status" aria-live="polite">Copied</p>
<!-- Single line with a shell prompt. The $ is decoration. -->
<div class="ds-code ds-code--inline-block">
<span class="ds-code__prompt" aria-hidden="true">$</span>
<code>npm install @acme/sdk</code>
<button type="button" aria-label="Copy command">…</button>
</div>CSS
.ds-code {
position: relative;
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-lg);
background: var(--ds-surface-inset); /* a well, not a raised card */
}
.ds-code pre {
margin: 0;
padding: 14px;
max-block-size: 24rem; /* ~24 lines, then scroll */
overflow: auto;
font-family: var(--font-mono);
font-size: 13px; /* mono runs optically large */
line-height: 1.55; /* looser than prose: scanned as columns */
tab-size: 2;
}
/* Never selectable, never copied. This is the bug that ships most often. */
.ds-code__gutter,
.ds-code__prompt {
user-select: none;
-webkit-user-select: none;
color: var(--ds-fg-disabled); /* allowed: not content */
}
.ds-code__copy {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
inline-size: 28px;
block-size: 28px;
}
/* Hover-reveal is fine on a mouse and catastrophic on touch. */
@media (hover: hover) and (pointer: fine) {
.ds-code__copy { opacity: 0; transition: opacity 120ms; }
.ds-code:hover .ds-code__copy,
.ds-code__copy:focus-visible { opacity: 1; }
}
@media (pointer: coarse) {
.ds-code__copy { inline-size: 44px; block-size: 44px; }
}
/* No wrapping. A copied fragment with an invented newline is a broken command. */
.ds-code--inline-block code { white-space: nowrap; overflow-x: auto; }Component API
CodeSnippet
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | string | — | The exact source. This string is what gets copied — never the rendered DOM. |
| lang | 'tsx' | 'ts' | 'js' | 'html' | 'css' | 'bash' | 'json' | 'text' | 'text' | Drives highlighting and the announced language. |
| prompt | string | — | A decorative shell prefix. aria-hidden and unselectable; never part of the copied value. |
| showLineNumbers | boolean | false | Adds an unselectable gutter. Only useful when the surrounding prose references line numbers. |
| wrap | boolean | false | Off for commands, where an invented newline breaks the paste. Acceptable for prose-like config. |
| maxHeight | number | 460 | Pixel height past which the block scrolls rather than growing. |
CopyButton
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Exactly what lands on the clipboard. |
| label | string | 'Copy' | Accessible name. Must identify the value on any page with more than one copy control. |
| timeout | number | 1600 | How long the confirmation holds, in ms. |
Professional tips
- Offer a package-manager switcher (npm / pnpm / yarn / bun) on install commands and remember the choice across the whole site. It is the single highest-value affordance on any docs page.
- Show the copy control on focus as well as on hover, or keyboard users never discover it exists.
- Truncate long single-line values in the middle rather than the end — the tail of a key is what people check against.
- If a snippet contains a placeholder the user must replace, mark it visually and keep it in the copied string. Silently substituting their real key is worse than making them edit one word.
Performance
- Highlight on the server or at build time where you can. A client-side highlighter on twenty blocks is a measurable chunk of main-thread time on first paint.
- Do not re-highlight on every render. Memoise on the source string and the language, or a page of snippets re-tokenises on every keystroke elsewhere.
- Virtualise blocks over roughly 500 lines — but a 500-line block in a doc page is a design problem before it is a performance one.
- The clipboard API is async and can reject on an insecure origin. Always keep the legacy fallback; failing silently on http:// is a support ticket nobody can diagnose.
Common mistakes
- Copying innerText, so line numbers and the shell prompt land on the clipboard.
- No confirmation, so users press the button repeatedly and then paste to check.
- A visual tick with no live-region announcement, leaving screen-reader users with no feedback at all.
- Every copy button on the page named "Copy", so assistive tech reports six identical controls.
- Hover-only copy controls, which are invisible on every touch device.
- Syntax colours ported straight from a code editor, where comments routinely sit around 2.5:1.
- A scrollable block with no tabindex, unreachable and unscrollable by keyboard.
Real-world recommendations
- On docs pages, copy rate is one of the few honest engagement metrics — it means someone is actually running the thing rather than skimming.
- API-key screens should show the value exactly once, mask it thereafter, and keep copy working while masked. Every product that forces a reveal to copy gets keys screen-shared into meetings.
- For multi-step setup, give each step its own snippet with its own copy control. One block containing five commands guarantees someone runs all five when they only meant to run the third.
- Include the expected output as a separate, non-copyable block. Users need to know what success looks like, and they must not be able to paste it back into a shell.