data-display — numbers that read instantly
Stage: Phase 6 — Build - Reads: design/SYSTEM.md, design/SITEMAP.md, design/BRIEF.md - Writes: components/data/* (tables, stat blocks, charts)
Standard
A first-grade data surface is scannable in 3 seconds: eyes land on the biggest number, compare down a column without wobble, and leave with one takeaway. Concretely: every numeric column right-aligned in tabular figures, one decimal precision per column, units stated once, semantic table markup, all four async states designed, and no chart that a sentence plus a big number would beat. Data is where craft shows — a misaligned decimal column reads as sloppiness everywhere else.
Process
- Read SITEMAP.md for each data surface. Decide the cheapest form that answers its question, in order: sentence → stat block → table → chart. Escalate only when the cheaper form loses information.
- Per table, write the column spec: content type (text/number/date), alignment, precision, unit placement, and a priority rank (drives responsive behavior).
- Build with semantic HTML —
<table>for tabular data, always — styled from@themetokens. - Apply alignment rules; confirm the SYSTEM.md font renders tabular figures (see below).
- Choose ONE responsive strategy per table; verify at 375px with a screenshot, not a guess.
- Add loading/empty/error states via ultraweb:ui-states.
- Charts last: write the one-sentence takeaway first. If the sentence suffices, ship it with a stat block instead of the chart.
Alignment rules
- Numbers right,
tabular-nums, always. Verify the chosen font ships tabular figures (tnum) — not all do; if it doesn't, define a numeric-safe font token in@themefor data surfaces and flag it to ultraweb:typography. - Text left. Center only single-glyph cells (status dot, check). A centered text column is the fastest way to look amateur.
- Headers align with their column's content — numeric header right, text header left.
- One precision per column: "4.0 / 12.5 / 7.3", never "4 / 12.5 / 7". Format with
Intl.NumberFormat(locale separators for free), not string math. - Units once, in the header — "Price (EUR)", "Weight (kg)" — not per cell. Currency symbol per cell only when currencies mix within a column.
- One date format sitewide; pick it in the column spec and stop re-deciding.
Table variants
- Dense — 40–44px rows, 13–14px text, hairline row separators. For real datasets users scan and compare: dashboards, leaderboards, logs.
- Comfortable — 56–64px rows, 15–16px text, generous cell padding. For marketing-context tables (plans, spec highlights) where the table is also a design surface.
- Definition — two columns, muted label left / value right. For spec sheets and key-value detail panels; beats a bulleted list every time.
Responsive strategies
- Priority columns — rank columns, hide the low-ranked at breakpoints (
hidden md:table-cell); the identity column plus the key metric survive to 375px. Default for ≤6 columns. - Card collapse — below 640px each row becomes a card with inline labels (shares anatomy with ultraweb:cards). For heterogeneous rows or rows with touch actions.
- Scroll container —
overflow-x-auto, sticky first column, gradient fade edge signalling more content. Last resort, for true matrices (feature comparisons) only — never the lazy default for a 4-column table.
Stat blocks
- Stat row: 3–4 KPIs. Value 2.5–3.5rem, semibold+,
tabular-nums; label 0.8125–0.875rem, muted, consistently above or below across the row. Delta ("+12%") gets a direction icon plus semantic color — never color alone. - Count-up: 400–700ms, ease-out, once on first view; under
prefers-reduced-motionrender the final value immediately. - Framed Data (
award-canon, an optional signature for ONE hero figure): wrap the count-up in a thematic SVG<mask>/clip-pathframe that embodies the subject — a gauge, a filling vessel — so the figure reads as embodied, not floating (The Other Side of Truth framed war stats in bullet-hole vignettes, SOTY 2022). The frame is SVG/CSS; the number stays real DOM text with the full value inaria-label, never a canvas glyph. Static / reduced-motion fallback: the final number already inside the same frame. Reserve it for the hero stat — framing every figure is noise. - Caption the timeframe/source ("last 30 days") — an unanchored number reads as marketing fiction.
Chart restraint
- One question per chart. Title states the answer ("Signups doubled after launch"), not the axes ("Signups over time").
- Series colors from
@themetokens only — accent plus a neutral covers most charts; 3+ series get direct labels at line ends, not a legend color-hunt. - Bars start at zero, always. Lines may crop the range but must show axis values.
- Gridlines: ≤5 horizontal hairlines, no vertical grid, no border box, no axis you don't need.
- Banned: 3D, dual y-axes, pies beyond 3 slices (use bars), gradient fills as decoration, a legend for a single series.
- Tooltip: token-styled, exact values in
tabular-nums, 150–250ms fade (needs"use client"; keep chart clients leaf components). - Sparklines and simple bars: hand-rolled SVG beats a chart dependency — a marketing site rarely earns one. If real interactivity demands a library, verify current options against docs first, then wrap it so every color and font flows from tokens.
States
- Loading: skeleton mirrors the real column widths and a plausible row count — a spinner in a void tells the layout nothing.
- Empty: designed message plus the next action ("No invoices yet — create your first"), per ultraweb:ui-states.
- Error: what failed plus a retry affordance; never an empty table pretending to be zero rows.
- Interactive rows: hover background one token step, focus-visible ring, active press; sorted column shows
aria-sortplus a direction icon on the active column only.
A11y
- Every table gets a
<caption>(visually-hidden if needed) oraria-label;<th scope="col">on headers,<th scope="row">on row identity cells. - Row interaction lives on a real link/button inside a cell — an
onClickon<tr>has no keyboard path. - Charts get a text equivalent: the takeaway sentence adjacent, or a visually-hidden table of the data.
- Scroll containers are keyboard-reachable:
tabindex="0"+role="region"+aria-labelon the wrapper. - Status and deltas never by color alone — icon or text carries the meaning.
Anti-patterns
text-centeron data cells- numeric columns missing
tabular-nums onClickon<tr>with no focusable child- zebra stripes + row borders + hover highlight stacked together — pick ONE separator
- units repeated in every cell ("$4.00, $5.00, $6.00" down a column)
- div-soup masquerading as a table when the data IS tabular
- pie with >3 slices; donut-with-centered-KPI as the default stat
- fake precision ("99.99999% uptime")
overflow-x-autoas the only mobile plan for a narrow table
Worked example — Tidepool, berth-utilization surface on /product
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.