Population Pyramid
A four-state back-to-back band chart — two cohorts mirrored on one shared scale, with per-side totals, an earlier period overlaid as dashed outlines, and every band keyboard-reachable with a readout.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pyramid.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPyramid" card — two cohorts drawn
back to back around a shared central axis — with zod. No chart library: the
whole plot is one fixed-layout table plus percentage widths, so text stays at
its real size at every container width and nothing ever has to be measured.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
sides: { left: string; right: string };
unit?: string; bandAxisLabel?: string; baselineLabel?: string;
bands: { id: string; label: string;
left: number >= 0; right: number >= 0;
leftBaseline?: number >= 0; rightBaseline?: number >= 0 }[] }.
left/right are MAGNITUDES measured outward from the axis; the direction is
carried by which half a bar sits in, never by the sign of a number. Bands
render in the order given — band order is data (age runs one way, tenure
the other) — so the component never sorts them.
- Component props = z.infer of the schema plus the display levers:
mode ("count" | "share", default "count"), showBaseline (default true),
showValues (default true), barHeight (clamped 6-32, default 18, or 12 above
14 bands), axisIntervals (clamped 1-6, default 4), minBarWidth (clamped
0-8, default 2), formatValue, locale (default "en-US"), skeletonBands,
onRetry, emptyState, className, plus the native div props through
forwardRef.
- Ship a pure module beside the schema: inspectPyramidBands() for the
structural pass, niceAxis(rawMax, intervals) for the ceiling and its ticks,
and buildPyramidLayout() returning { ok: true, layout } | { ok: false,
issue }. The layout carries, per row, the display values, the raw counts,
the bar fractions, the lead, the ratio and the share of the combined total,
plus axisMax, ticks, per-side totals, baseline totals, the peak band, the
most skewed band and an allZero flag. Keep it DOM-free so the same geometry
can feed an export, a PDF or a test.
Behavior
- ONE SHARED SCALE is the whole point. Both halves are drawn against a single
axisMax = the maximum over every displayed value on both sides AND both
baseline outlines. Scaling each half to its own maximum is the exact failure
this chart exists to prevent: every individual bar stays "correct" while a
40/60 split gets drawn as 50/50. The outlines are inside the picture, so
they are inside the scale too, or an earlier period runs off its track.
- NICE CEILING: step = ladder(rawMax / intervals), where ladder snaps to
1 / 2 / 2.5 / 5 x 10^n; axisMax = ceil(rawMax / step) * step with a 1e-9
epsilon so a maximum that already IS a multiple of the step does not gain a
whole empty interval from float noise; ticks are step, 2*step ... axisMax.
The interval count is what bounds the waste: at 4 the empty headroom is
usually under 10% and capped near 50%, while at 2 a maximum of 561 is drawn
against a ceiling of 1,000 — barely half the track.
- mode="share" divides every value by ITS OWN side's total (and each baseline
by the baseline's own total), which is the only way two cohorts of very
different size can be compared band for band. It also hides the size
difference, which is why the per-side totals and their percentages stay
printed above the plot in both modes.
- DEGENERATE DATA, each handled on purpose rather than by accident:
- zero bands with status "ready" -> render the empty branch, not an axis
with nothing on it;
- every value 0 -> axisMax falls back to 1 with NO ticks, every bar gets
width 0, and the chart says every band reads zero instead of looking
like a successful render of nothing;
- one band -> the entire scale comes from one number, which is fine as
long as nothing divides by rows.length - 1;
- all values equal -> every band ties, so the most-skewed band is null and
the summary says no band leans, rather than promoting rounding noise to
a finding;
- one side 0 inside a band -> the ratio is undefined; print the lead and
the difference and never an infinity;
- a negative, a NaN/Infinity, or a duplicate band id -> refuse to draw and
name the band. A negative would have to point ACROSS the axis into the
other cohort's half, where it reads as that cohort's data; one
non-finite value takes axisMax with it and collapses every bar to NaN%;
a duplicate id collides as a React key and the two rows shadow each
other.
- MINIMUM BAR WIDTH: a band holding 0.1% of the ceiling is a fifth of a pixel
and simply disappears. Floor the painted width with CSS
max(<pct>%, <floor>px) — the browser is the only party that knows the track
width, so there is nothing to measure and nothing to re-measure on resize.
Be honest about the trade: below the floor the width no longer encodes the
value; the exact number is always in the row and in the readout, and
minBarWidth={0} restores strict proportionality.
- KEYBOARD: the plot is a real table and the ROW is the unit of interaction.
One roving tab stop over the rows (Tab enters at the last focused row, else
the first), ArrowUp / ArrowDown move by one band, Home / End jump to the
ends, all CLAMPED and never wrapped — wrapping from "65 and over" back to
"18-24" reads as a jump in the data, not a jump in the focus. Move focus
with a synchronous .focus() on the target row and let the focus event update
state instead of waiting for a render. Delegate mouseover / focus / blur /
keydown to the tbody: one listener each, instead of four per row.
- POINTER AND FOCUS both feed one readout line, hover wins while it is inside
the plot, and the readout falls back to the focused row when the pointer
leaves. The tab stop follows FOCUS only — a moving mouse must never change
where Tab lands.
- The four states are first-class branches of one bg-card panel: a mirrored
pulsing skeleton (aria-hidden, with an sr-only role="status" beside it), an
empty state, an error state that prints either the transport failure or the
specific data issue and offers "Try again" only when onRetry exists, and
ready.
- CLEANUP: there is nothing to clean up, and that is a decision, not an
omission — no timers, no rAF, no ResizeObserver, no window listeners.
Responsiveness is percentage widths inside a fixed-layout table plus one
container query on the tick labels, so the component is correct on its first
paint, during SSR, and at every width, without measuring anything.
Rendering & styling
- Layout is one table with table-fixed and five columns declared in a
colgroup: [left number][left track][band label][right track][right number].
The two track columns declare NO width, so the fixed-layout algorithm splits
whatever is left over exactly in half — that is what keeps the two halves
mirrored however long a label or a number gets. The number columns are sized
from the widest formatted value in ch units (capped at 12ch, and 0px when
showValues is false, padding included), the band column from the longest
label (capped at 14ch), so a seven-digit count or a forty-character label
moves the columns and never the scale. A min-width of
(numbers + label + 2 x 72px) scrolls the card horizontally rather than
letting a track collapse.
- Bars are absolutely positioned inside a relative track: the left bar is
anchored right and grows leftward, the right bar anchored left, both
inset-y-0, both rounded on their outer end only. The width is a percentage
of the track, so nothing is recomputed when the container changes.
- Gridlines cost zero DOM. Each track paints its ticks as ONE
repeating-linear-gradient measured from the axis with period 100/ticks
percent, plus a single gradient pinned at the outer edge (the repeating
layer's last line falls exactly on the boundary and is clipped away), plus a
stronger axis line from color-mix(in oklab, var(--foreground) 30%,
transparent) — not --border, which is oklch(1 0 0 / 10%) on the dark card
and vanishes exactly where every bar is measured from.
- Colour is the THIRD channel, never the only one. Left is var(--chart-1),
right var(--chart-2) — the two most separated hues in the palette — but the
two tokens sit about 0.08 apart in lightness, so a greyscale printout would
lose them. The right side therefore also carries a 45-degree hatch built
from one step toward --foreground and one step toward --card at equal
strength, so it adds about as much ink as it removes and neither side reads
heavier than its number deserves. On top of that: position (which half of
the axis), a printed number per row, and a legend chip that repeats the fill
AND the hatch.
- The comparison period is a DASHED OUTLINE in border-foreground/70, not a
second fill: an outline reads over the bar it overlaps and over the card it
sticks out onto, and "dashed" survives greyscale and colour vision
deficiency in a way a second hue does not. An outline for a genuine zero
collapses to its own borders, which is what zero looks like.
- Axis tick labels live in a tfoot row, absolutely positioned at
right: (tick/axisMax)% inside the left track and left: (tick/axisMax)% in
the right one, each translated half its own width to centre on its gridline
— except the outermost, which sits at 100% and is pulled fully inside
instead of hanging half of itself outside the plot. Counts use compact
notation ("1.2K") so a label costs as little track as possible; shares use
percent.
- TICK LABELS THIN THEMSELVES, or a narrow half turns them into mush. A
centred label needs one of its own widths of track between two gridlines and
the pinned outermost one eats one and a half, so budget about 12px per
character plus air and test that budget against the TRACK — never against
the card, whose number and label columns take an arbitrary bite out of it
first — with a container query on the tick row itself. With room for the
whole set, print every tick; with room for half, print every second one
counting inward from the ceiling; with less, print the ceiling alone, which
with the 0 in the middle column still states the range. The gridlines never
thin and the ceiling label never disappears. Because the thresholds are
static classes chosen from the label text, this stays a layout the browser
resolves: nothing is measured, nothing is re-measured on resize, and the
first paint is already right.
- ACCESSIBILITY, in three layers. (1) An sr-only paragraph BEFORE the table
states the finding: band count, the shared range, both totals with their
shares, which side leads and by how much, the widest band, the strongest
lean, and what the outlines are. (2) The plot IS the data table — an sr-only
caption, real column headers for the two cohorts and a row header per band —
so the text alternative cannot drift out of sync with the picture the way a
duplicated hidden table does. (3) Every row carries an aria-label with its
full sentence and takes part in the roving tab stop; the visible readout
line repeating that sentence is aria-hidden, because the focused row already
announces it and a live region would say everything twice. Do not also put
aria-labelledby on the table: it would suppress the richer caption. The
tfoot is aria-hidden — "0 · 3K · 6K" read out of a table row teaches nobody
anything.
- Motion is decorative only: bar widths transition, rows fade their highlight,
the skeleton pulses, and all three switch off under motion-reduce. Nothing
about reading the chart depends on any of them.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
text-destructive, ring, var(--chart-1), var(--chart-2), and color-mix over
--foreground / --card for the hatch and the axis. No hex, no raw oklch.
Customization levers
- mode: "share" when the two cohorts differ enough in size that the count view
is all size and no shape; "count" when the size difference IS the story.
Nothing else about the chart changes with it.
- axisIntervals: 4 by default. This is a choice about the SCALE, not about
fitting labels — a half too narrow to print them all drops them itself. Take
it to 2 for a coarser, rounder step (accepting more empty headroom), 5-6 on
a wide dashboard tile for a finer one.
- barHeight + showValues: 18px with numbers is the reading layout; 10-12px
with showValues={false} collapses both number columns to zero width and
leaves a pure silhouette for a dense grid — the values stay in the DOM for
screen readers either way.
- minBarWidth: 0 for strictly proportional bars, 4-8 when the smallest bands
matter more than the arithmetic.
- showBaseline={false} drops the earlier period entirely, scale included, so
the current period gets the whole track back.
- formatValue + locale: one function covers rows, ticks, totals and the
summary, so a currency or compact formatter propagates everywhere at once.
- Palette: re-point the two fills at any two --chart-* tokens; keep the hatch
on exactly one side (that is the greyscale channel) and keep the legend
chips painting fill and hatch identically to the bars.
- Interaction: the readout is the only affordance shipped. Wire onClick on a
row to drill into a band, or lift the focused band id out through a callback
to cross-filter a sibling chart.Concepts
- Shared ceiling — both halves are measured against one maximum taken over both cohorts and both outlines. Scaling each half to its own maximum is the failure this chart exists to prevent: every individual bar stays "correct" while a 40/60 split is drawn as 50/50.
- Share vs count —
sharedivides each side by its own total so two cohorts of different size can be compared band for band;countkeeps the raw numbers so the size difference stays visible. Becausesharehides exactly that difference, both modes keep the per-side totals printed above the plot. - Baseline outline — the earlier period is a dashed, unfilled rectangle rather than a second colour, so it reads both over the bar it overlaps and over the card it sticks out onto, and it survives greyscale and colour vision deficiency.
- Bar floor — a floor in pixels that buys discoverability at the cost of proportionality: beneath it a bar's width no longer encodes its value, so the exact number stays in the row and in the readout, and a floor of 0 restores exact proportionality.
- Labels thin, gridlines don't — how many tick labels a half can print is decided by that half's own width, not the card's, and it is decided in CSS: a container query on the tick row drops labels inward from the ceiling as the track narrows, so the worst case is a coarser axis rather than two numbers printed on top of each other. The gridlines and the ceiling label survive every width, and no width is ever measured.
- Row as the unit of interaction — hover and keyboard both select a whole band rather than one bar, so the two cohorts are always read together; one roving tab stop means the chart costs one Tab instead of one per band, and the arrow keys clamp because band order is data.
- The plot is the table — the bars live inside a real table with row and column headers, so the text alternative is the chart itself instead of a hidden copy that can drift away from it; only the finding-level summary is duplicated, once, as an sr-only sentence.
Chart Bubble
A four-state bubble chart in plain SVG: area-proportional radii from a zero baseline, hue paired with fill texture for the fourth dimension, and an optional time slider that interpolates positions between snapshots.
Hexbin Density Plot
A four-state hexbin that bins the raw scatter itself — nearest-centre binning, sqrt / linear / quantile bands, colour paired with hexagon size, a live bin-radius control and a ranked screen-reader table.