Skip to content
misoto22 design

Display

CodeBlock

A multi-line snippet, on a plate, with a way to take it away.

Usage

When to reach for it

Any snippet longer than a word. Pass html when a build-time highlighter has already run; pass code alone when it has not.
TSX
import { CodeBlock } from '@misoto22/design'

Notes

A multi-line snippet, on a plate, with a way to take it away.

The strip along the top carries the title, the language and the copy button, and it is there whenever there is anything to put in it. It is deliberately not a hover affordance: a control that appears on hover does not exist on a touch screen, which is where a reader is most likely to want the snippet and least able to select it by hand.

Line numbers live INSIDE their own line's row rather than in a parallel gutter column. Two columns sharing a line-height align right up until one of them wraps, ships a different font, or scrolls on its own; a number that is a child of the line it numbers cannot come apart from it. The cost is that a number scrolls away with its line: it is inside the scrolling box, not in a gutter beside it. A gutter that stayed put would be a second column to keep in sync, which is the failure this avoids.

The body is a focusable, named role="group", and both halves of that are load-bearing. Focusable, because a scrollable box whose contents are not themselves focusable is unreachable by keyboard — there is nothing to Tab to and therefore no way to press an arrow key at it, so the right-hand half of a long line does not exist for anyone not using a mouse. Named, because a tab stop that announces nothing lands the reader in an anonymous box and leaves them to work out what they have arrived in.

group rather than region, and that is the deliberate half. A region is a LANDMARK — one of the handful of major sections a reader navigates a whole page by — and a snippet is not one of those. Three fenced blocks in one article would put three landmarks called "Code" into that map, which is exactly the noise the landmark-unique rule exists to catch, and it would push the page's real landmarks down a list nobody can now skim. group carries the same accessible name to the same reader on the way in, and carries it nowhere else.

For a function name inside a sentence, reach for Code.

Anatomy

CodeBlock anatomy
ElementDescription
PlaterequiredThe --paper-2 box on the --radius-lg corner with a --rule hairline. One elevated step, not a second surface colour.
StripThe bar along the top: title at the start, the language label at the end, the copy button after it. Present whenever there is anything to put in it, and never a hover-only affordance.
Copy buttonA ghost iconOnly Button that puts code — the string, never the rendered markup — on the clipboard, and flips its own accessible name to “Copied” for 1.6 seconds.
BodyrequiredA focusable, named role="group" that scrolls in both axes. Focusable because a scrollable box containing nothing focusable is unreachable by keyboard: there is nothing to Tab to, so the right-hand half of a long line does not exist without a mouse. A group and not a region, because a region is a landmark and a snippet is not one — three fenced blocks in an article would be three landmarks all called Code.
Line rowOne <span data-line> per line on the plain path, carrying its own number and its own highlight band. The number is a child of the line it numbers, so the two cannot come apart.

Best practices

Do

  • Always pass code, even alongside html. It is what the copy button copies: a block that copies its rendered markup hands the reader a wall of spans, and one that scrapes textContent back out of the DOM is a non-breaking space away from pasting something that does not run.
  • Highlight at build time and pass html. A highlighter is a few hundred kilobytes of grammar shipped to re-derive spans that never change, which would be the largest thing on the page.
  • Give maxHeight to a long snippet rather than letting it run. The body scrolls and is focusable, so what is past the fold stays reachable by keyboard.

Don’t

  • lineNumbers and highlightLines are typed out of the html form and passing both is a compile error. They are a per-line structure, and html is one opaque string the component does not parse — which is why the type says so rather than the prop quietly rendering nothing.
  • html is dangerouslySetInnerHTML. It is for the output of your own highlighter over your own source; markdown a reader wrote goes to code as a string, where it renders as text and cannot be mis-executed.
  • Do not drop copyable to tidy the strip. The button is the reason a reader stops selecting a wrapped command by hand, and on a touch screen a manual selection is most of the interaction.

Examples

numbered and banded

The plain path: no highlighter, a title, a language label, numbered lines and one banded line. The number lives inside its own line's row rather than in a parallel gutter column — two columns sharing a line-height align right up until one of them wraps. The copy button copies the code string, never the rendered markup, and the strip is always there rather than appearing on hover, which on a touch screen means never.

cn.tsTypeScript
import { clsx } from 'clsx'import { twMerge } from 'tailwind-merge'export function cn(...inputs) {  return twMerge(clsx(inputs))}

a command to copy

The smallest useful block: one line, a language, and the copy button. The strip is not a hover affordance and never has been — a control that appears on hover does not exist on a touch screen, which is exactly where a reader is least able to select a wrapped command by hand. What it copies is the code string, so nothing about the rendering can end up on the clipboard. label rather than title, because the block stays bare: the name is for the scroll region, not for a header strip. Two blocks side by side both fall back to "Code" without it, and two regions sharing one name are two landmarks a reader cannot tell apart.

Shell
pnpm add @misoto22/design
Shell
pnpm --filter @misoto22/design build

highlighted somewhere else

Markup a highlighter produced, rendered as it arrived. The package does not highlight and will not: a grammar is a few hundred kilobytes and a build-time job, so this site runs Shiki inside its generator and hands the result here. Passing html takes lineNumbers and highlightLines away in the TYPE rather than leaving them as props that quietly render nothing, because html is one opaque string this component does not parse. code stays required — it is what the copy button puts on the clipboard, and it is the half a reader takes away.

tone.tsTypeScript
export function tone(state: string) {
  return state === 'ok' ? 'success' : 'danger'
}

output nobody runs

A log, not a command — copyable={false} is for the block nobody is meant to run, and this is the case it exists for. Everywhere else the button stays: dropping it to tidy the strip takes away the reason a reader stops selecting a wrapped command by hand. With no title, no language and no copy button there is nothing to put in the strip, so there is no strip — and the scrolling body still needs a name, which is what label is for.

> @misoto22/design buildtokens      → dist/tokens.css     145 tokenscomponents  → dist/index.js        61 exportstypes       → dist/index.d.tssize check  → within budgetdone in 4.2s

Accessibility

  • The scrolling body is tabbable and carries role="group" with a name, so its overflow is reachable with a keyboard and the tab stop announces what it is. Deliberately not role="region": that is a landmark, and a page with two code samples would put two of them in the landmark map under one name.
  • The copy button is an iconOnly Button with a required aria-label that becomes “Copied” on success — the state change is announced rather than only drawn.
  • The copy control clears the 44px pointer target on a coarse pointer, where the compact strip alone would not (WCAG 2.5.5).