Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, CopyX } from "lucide-react"
import { cn } from "@/lib/utils"

/** One rung of the scale: a token name plus the classes that *are* that token. */
export interface TypeScaleStep {
  /** Token / step name. Labels the row and is what Copy writes by default. */
  name: string
  /** Utility classes applied to the specimen line — the metrics are read back off it. */
  className: string
  /** Per-row specimen text. Overrides the switcher for this row only. */
  sample?: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/type-scale.json

Prompt

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 honestyline-height: normal and letter-spacing: normal have no number until a font is chosen, so the row prints the keyword instead of a fabricated 0 or a ratio computed from NaN.
  • Provisional first measurement — the pass on mount happens before webfonts settle, so document.fonts.ready and a ResizeObserver re-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.

On This Page