Type Scale
A living typography specimen: every step set in real type, its px, rem, leading and tracking read back off the DOM, with copy-the-token rows and a heading / paragraph / numerals switcher.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/type-scale.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TypeScale" component: a typography
specimen that measures itself. lucide-react for icons, cn() (clsx +
tailwind-merge) for class merging. No animation library, no measuring library —
the numbers come from getComputedStyle on the rendered specimen.
Contract
- Export a forwardRef <div> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "onCopy">.
- steps: { name: string; className: string; sample?: string }[] — one rung per
entry, top rung first. `className` IS the token: it is applied to the specimen
line and every printed number is read back off that element.
- samples?: Partial<Record<"heading" | "paragraph" | "numerals", string>> —
overrides for the built-in specimen texts.
- sample?: "heading" | "paragraph" | "numerals" (controlled),
defaultSample = "heading" (uncontrolled), onSampleChange?(sample).
- showSwitcher = true, metrics = ["size", "leading", "tracking"] where a metric
is "size" | "leading" | "tracking" | "weight" (printed in the order given).
- copyValue?(step, measurement) => string — default: step.name.
- onCopy?(text, step) fires only after the text really reached the clipboard;
onCopyError?(error) when it did not.
- label = "Type scale" (toolbar accessible name),
emptyMessage = "No type steps to show.".
- A measurement is { fontSizePx, fontSizeRem, lineHeightPx, lineHeightRatio,
letterSpacingEm, fontWeight }, where lineHeightPx / lineHeightRatio /
letterSpacingEm are null when CSS resolved that property to the keyword
`normal`. Nothing in it is authored by the caller.
Behavior
- Measuring. In an effect keyed on a joined "name + className" key, collect the
specimens with root.querySelectorAll('[data-slot="type-scale-specimen"]') —
document order is row order, and a live node list cannot disagree with what is
painted. For each node read getComputedStyle and derive:
fontSizeRem = fontSizePx / parseFloat(getComputedStyle(documentElement).fontSize)
lineHeightRatio = lineHeightPx / fontSizePx
letterSpacingEm = letterSpacingPx / fontSizePx
rem is relative to the ROOT font size, not to this subtree, so a reader who
set their browser to 20px sees the rem column shrink. Tracking is normalised
to the step's own size so it survives a size change.
- Keyword honesty. computed line-height and letter-spacing can be the string
"normal", whose real value belongs to the font, not to the token. Store null
and print "leading normal" / "tracking normal" — never a fabricated 0 or a
ratio derived from NaN. A node whose font-size is unusable (0, or a detached
node, whose computed lengths come back empty) measures as null and prints
dashes.
- Re-measuring. Measure once on mount, again after document.fonts.ready (the
first pass is provisional: `normal` leading and every rem depend on the font
that finally loads), and on a ResizeObserver of the root coalesced through one
requestAnimationFrame — responsive steps such as "text-xl md:text-3xl" change
at a breakpoint and at no other moment. Before setting state, compare the new
measurements field by field and keep the previous array when nothing moved;
without that guard the observer and the state form a loop.
- Staleness. Store the measurements as { key, list } where key is the steps key,
and treat a mismatched key as "not measured yet". Otherwise, for one frame
after the caller swaps scales, the old numbers paint against the new rows.
- First paint. The effect runs after paint, so the server HTML and the first
client frame both print dashes and there is no hydration mismatch.
- Copying. Clicking or pressing Enter / Space on a row writes copyValue(step,
measurement) — the step name by default — through navigator.clipboard, which
is wrapped so a missing or rejecting Clipboard API resolves false instead of
throwing. A ref latch is read AND written synchronously at the top of the
handler: a state flag is still stale inside the second handler of a double
click, and two overlapping writes would fight over the reset timer. Success
shows a tick, failure a cross in the destructive token; both clear after
1600ms and the timer is cleared on unmount and before it is re-armed.
- Announcing. A single sr-only role="status" aria-atomic region says "Copied
display" or "Could not copy display: the clipboard is unavailable", then is
emptied by the same timer — a screen reader stays silent on unchanged text, so
clearing is what lets the next identical result be announced at all.
- Sample switcher. Three radios swap the specimen text of every row that does
not carry its own `sample`. Controlled when `sample` is passed, otherwise the
component owns it; either way onSampleChange fires. Hide it with
showSwitcher={false} (and pin `sample`) for compact panels; it is also hidden
when there are no steps, because there is nothing to switch.
- Keyboard. Two composite widgets, two Tab stops in total.
Rows (vertical toolbar, roving tabindex): ArrowDown / ArrowRight = next row
cyclically, ArrowUp / ArrowLeft = previous, Home / End = first / last, all
with preventDefault; focus moves and nothing is copied — a scale you cannot
read without filling the clipboard would be hostile. Enter / Space copy via
native button activation. Only the row last focused is tabbable, and that
index is clamped when `steps` shrinks.
Switcher (radiogroup, roving tabindex): ArrowRight / ArrowDown,
ArrowLeft / ArrowUp, Home / End, selection follows focus. If an invalid
value ever arrives at runtime the index falls back to 0, so exactly one
radio always stays tabbable and the group can never trap Tab.
- ARIA. The row container is role="toolbar" aria-orientation="vertical" with
aria-label={label}; each row is a type="button" whose aria-label deliberately
replaces its own text: "Copy display. 36 pixels, 2.25 rem, line height 40
pixels, letter spacing -0.025 em". The specimen sentence is a picture of a
font and reading it aloud on every row is noise — the numbers are the content,
and the label lists exactly the metrics the caller asked for. The switcher is
role="radiogroup" with role="radio" + aria-checked children. All icons are
aria-hidden.
- Degenerate input. steps=[] renders emptyMessage in a dashed panel instead of
an empty bordered box. Duplicate step names are legal (rows are keyed by name
plus index). A step with no text-* class inherits its container's size and the
component reports the inherited value, which is the truth.
- Cleanup. On unmount and on every steps-key change: disconnect the
ResizeObserver, cancelAnimationFrame the pending measurement, flip a cancelled
flag the fonts.ready callback checks, and clear the copy timer. A mounted ref
is re-armed in the effect body (not just initialised) so StrictMode's
mount-unmount-remount does not leave copy feedback dead in development.
Rendering & styling
- Semantic tokens only: the toolbar is `border bg-card divide-y rounded-lg`;
rows hover to `bg-accent/40`; the meta row is `font-mono text-xs tabular-nums`
in text-muted-foreground with the token name in text-foreground; the copied
tick is text-primary, the failure cross and its label text-destructive; the
switcher is a bg-muted track with a `bg-background` + shadow-sm selected pill;
the empty panel is `border border-dashed text-muted-foreground`.
- The specimen line is `block w-full min-w-0 truncate text-foreground` plus the
step's own className, merged through cn() so a step that sets its own colour
or leading wins. Truncation keeps every rung one line tall, which is what
makes the ramp readable as a ramp.
- Focus: focus-visible:ring-2 ring-ring, ring-inset on the rows (they are full
bleed inside the bordered card) and ring-offset-1 on the switcher pills.
- Motion is decoration only: colour transitions carry motion-reduce:transition-none
and nothing else animates, so the component is fully functional with motion off.
- Merge the consumer's className on the root and spread the remaining props
there too; the root itself has no ARIA role.
Customization levers
- metrics is the density lever: ["size"] for a sidebar, all four for a token
audit. Adding a metric means one union member plus one branch in the chip
formatter and one in the aria-label builder — both switch on the same union.
- copyValue is the integration lever: return step.name for Tailwind classes,
`--font-size-lg: 1.125rem;` for a CSS custom property, or JSON for a token
file. It receives the live measurement, so the copied value can be the
measured one rather than the authored one.
- samples plus per-step `sample` cover specimen content; add a fourth key
(for example a CJK or a mixed-script sample) by extending the sample union,
the order array, the label map and the default text map together.
- Layout density: px-3 py-3 on rows, gap-1.5 between the meta row and the
specimen. Swap `truncate` for `line-clamp-2` if you would rather see paragraph
specimens wrap; nothing in the measuring code depends on either.
- Look: drop `border bg-card divide-y` for a bare list inside an existing panel,
or raise hover to bg-accent for a denser, more clickable table.
- Reuse the shape: the same toolbar measures any scale whose value is visible in
the DOM — replace the specimen span with a box sized by a spacing or radius
token and read width / border-radius instead of font-size; the roving
tabindex, copy latch and status region are untouched.Concepts
- Readback, not a table — the row prints what the browser resolved for the specimen it just painted, so a retheme, a breakpoint or a reader's 20px browser default all show up by themselves; a hand-written specimen table starts drifting the day after it is written.
- Keyword honesty —
line-height: normalandletter-spacing: normalhave no number until a font is chosen, so the row prints the keyword instead of a fabricated0or a ratio computed fromNaN. - Provisional first measurement — the pass on mount happens before webfonts settle, so
document.fonts.readyand aResizeObserverre-measure, and a field-by-field comparison keeps the observer from looping against its own state update. - Roving-tabindex toolbar — the whole scale is one Tab stop; arrows walk the rungs and only Enter, Space or a click copies, so a keyboard reader can read the ramp without filling the clipboard.
- Synchronous copy latch — the one-write-per-press guard is a ref read and written in the same tick as the click, because a state flag is still stale inside the second handler of a double click.
- Names over specimens — each row's accessible name is its token plus its measured numbers, not the sample sentence: the sentence is a picture of a font, and hearing it repeated on every rung tells a screen-reader user nothing.
Column Picker
A table column manager: show/hide toggles, drag or keyboard reorder, pinned columns that refuse to hide with a reason, search and reset — emitting the ordered visible set.
Color Palette
A live specimen sheet for a theme's color tokens — chips grouped by role, values read back from the DOM, ink/fill pairing previews and one-click copy.