Marimekko Chart
A four-state mosaic chart where column width is each column's share of the total and stacked height is its internal mix, so a cell's area is its share of everything — with a minimum-width floor, texture tiers past the fifth band, and labels that drop out cell by cell.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-marimekko.jsonPrompt
Build a React + TypeScript + Tailwind "ChartMarimekko" card — a mosaic chart
laid out as absolutely positioned DOM tiles (no canvas, no chart library), with
zod for the contract and lucide-react for two state icons.
Contract
- One zod schema is the source of truth and the props are its z.infer plus a
few render knobs. Never hand-write a parallel interface.
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string; unit?: string;
series: { key: string; label: string }[]; // the stacked bands
items: { id: string; label: string;
values: Record<string, number> }[] } // the columns
`values` is keyed by series key: a missing key counts as 0, a key that is not
in `series` is ignored, so a wider API response needs no trimming.
- values are NON-NEGATIVE at the schema level. A mosaic encodes magnitude as
area and area has no sign; there is no honest rectangle for -40, and a chart
that draws one has to either flip it (wrong direction) or subtract it from a
neighbour (wrong neighbour). Signed movement belongs in a waterfall.
- Extra props: height (default 280, clamped 160-640), minColumnWidth (default
8, clamped 0-48), columnGap (default 2, clamped 0-16), formatValue, onRetry,
emptyState, className, plus forwardRef and the spread native div props.
Width is deliberately NOT a prop: the column widths are the data, so they
have to be re-apportioned whenever the container changes.
- Ship two pure modules beside the schema so the geometry is testable and the
component stays a renderer:
inspectMarimekkoData() -> null or { code, message, path } for the
structural faults: no bands, no columns, duplicate band key, duplicate
column id, every value zero.
buildMarimekkoLayout(series, items, { width, height, gap, minColumnWidth })
-> { columns, cells, total, gap, proportional, flooredColumns,
emptyColumns, droppedValues, widest, largest } with px x/y/w/h and
both shares on every cell.
Call inspect() from the schema's superRefine AND from the component: props
are z.infer types, so TypeScript alone does not stop a caller that never
parsed its feed.
Behavior
- THE DOUBLE ENCODING is the whole component and the two passes are computed
independently so neither can corrupt the other:
width_i = available * total_i / grandTotal (share of everything)
height_s = plotHeight * value_s / total_i (share of its own column)
Their product is the cell's area, which is its share of everything. Every
column is drawn to the full height, which is what normalises the mixes and
makes them comparable across columns of wildly different size.
- EXACT TILING: lay both axes out from cumulative sums, never by accumulating
one rectangle at a time. Each edge is size * cumulative / total, so float
error cannot pile up into a seam or an overlap and the last band of a column
lands exactly on its floor. Snap the last column's right edge to the plot
edge for the same reason.
- MINIMUM COLUMN WIDTH by water-filling, not by one pass. A 0.09% column in a
620px strip is 0.5px, thinner than its own seam. Pin every column whose
proportional width falls under the floor, then re-share what is left among
the rest — pinning one hairline can push the next-smallest under the floor in
turn. Scan smallest-first and one pass is enough: the ratio rest/openSum only
falls as columns are pinned, so the first column that clears the floor
guarantees every larger one does. Cap the floor itself at available/(2n), so
the pinned columns can never take more than half the strip: the obvious cap,
available/n, is only enough to stop an 8px floor on 120 columns claiming 960px
of a 600px strip, but hit it exactly and every column is forced to the same
width, which throws the encoding away even for the columns that could have
afforded it. Capping at half leaves the unpinned columns exactly proportional
among themselves. Be honest about the trade: a pinned column is wider
than the share behind it, so the layout returns `proportional: false` and the
card says so in its footnote and in its accessible summary. minColumnWidth=0
restores strict proportionality and lets the tail disappear.
- LABEL DROPOUT is per element, measured against that element's own box:
column header -> nothing at all under 30px, because there is no room for a
letter plus an ellipsis; otherwise label + share, each
clipped to the column so a long name can never reach over
its neighbour.
cell band name -> needs 48 x 30px
cell percent -> needs 28 x 15px
Below those it is not truncated, it is not rendered: a cell that shows only
an ellipsis is noise. Everything dropped stays reachable — native title on
hover, aria-label on focus, one readout line under the plot, and the sr-only
table.
- DEGENERATE DATA, each handled on purpose:
zero columns / zero bands / every value zero -> the empty branch, even when
the feed says "ready"; there is no rectangle for a share of zero.
one column, one band -> a single rectangle at 100% of everything.
all values equal -> equal widths and equal bands, nothing to rank.
a column whose total is 0 -> a hatched strip, not an invisible seam;
"measured and empty" must not look like "not measured".
a band whose value is 0 -> no tile at all. A 0px rectangle would still put
its seam on screen, and absent must not look like present but tiny.
negative / NaN / Infinity -> counted as zero and reported in the footnote.
a cell under 4px in either axis -> drop the seam. A 1px inset ring on both
sides of a 2px cell paints the whole cell in the card colour and the band
disappears, which is exactly the value that can least afford it.
- MEASUREMENT: a ResizeObserver on the plot box, attached through a CALLBACK
ref rather than useRef plus an effect. The plot only exists in the ready
branch, so an effect with an empty dependency list would run once while the
card was still a skeleton, find no node, and never observe anything. Hop the
state write through one requestAnimationFrame (writing straight from the
observer callback re-enters layout in the same frame and logs "ResizeObserver
loop completed with undelivered notifications") and ignore sub-pixel changes.
Cancel the frame and disconnect on unmount and on every re-attach. Until the
first measurement the plot shows the same skeleton the loading branch uses,
so SSR and the first client frame agree and nothing is ever drawn at a width
that is about to change.
- INTERACTION: one roving tab stop for the whole mosaic, not one per cell — at
8 bands x 12 columns that is the difference between 1 tab stop and 96.
Arrow Left/Right previous / next column in the same band
Arrow Up/Down previous / next band in the same column
Home / End first / last column that has a tile in this band
Ctrl or Cmd + Home / End first / last cell overall
Enter or Space pin this band, highlighting it in every column
Escape clear the pin (and only swallow Escape when there is a
pin to clear, so a dialog above the chart still gets it)
Navigation walks to the next PAINTED cell, because a zero band has no tile to
land on. Move focus with a synchronous .focus() on the target tile and let
the focus event carry the state update, so the move never waits for a render.
Pointer and focus feed the same readout line; leaving with the pointer must
not wipe a readout a focused cell still owns.
- The legend entries are buttons with aria-pressed that pin the same band, so
the highlight has a mouse route and a keyboard route to the same state. A pin
is derived against the current series list, so swapping the data can never
strand a highlight nobody can see or clear.
- Four first-class branches of one bg-card panel: a fixed-ratio pulsing
skeleton (aria-hidden, with an sr-only role=status line), an empty state, an
error state that shows either the transport message or the specific
structural fault plus a Try again button only when onRetry exists, and ready.
Rendering & styling
- Semantic tokens only: bg-card, border, bg-muted, text-muted-foreground,
text-foreground, text-destructive, outline-ring, outline-foreground, and
var(--chart-1..5) for the bands. No hex, no rgb(), no oklch().
- Band fill is var(--chart-N) at FULL strength, cycling every five. That
palette is five distinct hues with separate light and dark values, all
clearing 3:1 against the card; mixing it toward the card would trade away the
hue separation it was built for.
- COLOUR IS NEVER THE ONLY CHANNEL. Four other channels carry the same fact:
(1) the band order is identical in every column and the legend numbers it;
(2) every cell with room prints its own band name; (3) past the fifth band a
texture tier takes over — flat, then 45deg stripes, then a crosshatch, in
color-mix(in oklab, var(--card) 70%, transparent) — which keeps 15 bands
apart and, unlike hue, survives greyscale printing and colour vision
deficiency; (4) the legend swatch carries the fill AND the texture.
- IN-CELL TEXT is text-foreground over a text-shadow halo ring in var(--card),
the HTML equivalent of paint-order:stroke. This is what makes ONE text colour
legal on all five fills: measured against the raw tokens, --foreground runs
5.46:1 down to 1.38:1 in light and 4.09:1 down to 1.31:1 in dark, so without
the ring the last two bands would be unreadable in both themes. Fit the text
with CSS truncate inside a clipped box and let the browser measure it —
estimating advance widths in JS is off by tens of percent on all-caps runs
and on digits.
- Seams: an inset 1px box-shadow in var(--card) inside each tile, so the line
costs nothing from the rectangle the value earned, plus columnGap px of card
between columns. Hover draws a 2px foreground outline at -2px offset, focus a
2px ring outline; both raise the tile's z-index so the indicator is never cut
by a neighbour.
- A 0-100% share axis in a 34px left gutter. It is a SHARE axis, not a value
axis: 50% means half of this column, whatever the column is worth.
- Dimming the other bands mixes their own token toward the card
(color-mix(in oklab, var(--chart-N) 22%, var(--card))), NOT by dropping the
element's opacity: opacity fades the focus outline too, so arrowing into a
dimmed cell would land on a 25% focus ring — the one moment the indicator has
to be at full strength. It is a 150ms colour transition with
motion-reduce:transition-none, and nothing about the highlight depends on the
animation: with motion off the state still changes, instantly.
- ACCESSIBILITY: the plot is role="group" with an aria-label that carries the
finding, not the shape — total, column and band counts, what the two axes
encode, the widest column, the largest cell, and any floor / hatch / dropped
value note. role="img" is NOT usable here because the tiles are focusable and
role="img" makes its subtree presentational. Under it, an sr-only WRAPPER DIV
(never the table itself — CSS width is only a lower bound for a table box, so
width:1px does not hold one back and a narrow viewport picks up hundreds of
px of horizontal scroll) holds a real table: one row per column with its
total and share, one cell per band with both of its shares, and a foot row of
band totals. The visible footnote repeats the finding for everyone else.
Customization levers
- minColumnWidth: 0 for strictly proportional widths (fine while the widest to
narrowest ratio stays under about 100:1), 12-16 when the tail matters more
than the arithmetic and every column must stay clickable. It only changes
paint, never a number.
- height and columnGap set the density: a 200px height with gap 0 is a compact
dashboard tile, 480px with gap 4 is a review-deck exhibit. The three label
dropout thresholds are the other density dial — raise them for a calmer
chart, drop them to squeeze names into smaller cells.
- Band order is the reading order and is never re-sorted for you. Sort the
series array by value, by margin, or leave it in a fixed business order;
sorting the items array by total gives the classic wide-to-narrow mosaic.
- Palette: swap var(--chart-N) for a single-hue ramp when the bands are ordinal
(tiers, severity) rather than categorical, and keep the texture tiers — they
are the channel that still works when every fill is one hue.
- Interaction: the pin currently highlights a band. Wire the same handler to a
drill-down, a filter, or an onSelect callback; the cell object already
carries columnId, seriesKey, value and both shares.
- Swap the share axis for a cumulative axis along the bottom if your readers
are used to reading column boundaries as running percentages — but only when
minColumnWidth is 0, because a floored column makes those boundaries lie.Concepts
- Double encoding — width answers "how big is this column" and height answers "how is it split", so the product of the two answers "what share of everything is this cell". It only holds because the width pass is exact: the moment one column is widened to a floor, the areas in it stop being shares, which is why the layout carries a
proportionalflag and the card admits it out loud. - Water-filling apportionment — pinning one hairline column to the minimum width takes pixels out of the pool, which can push the next-smallest column under the minimum too. Scanning the columns smallest-first turns that cascade into a single pass. The floor is itself capped so the pinned columns can never take more than half the strip, which is what keeps a hundred-column chart from collapsing into a hundred identical slices instead of just squeezing its tail.
- Label dropout — text is never truncated down to an ellipsis with no letters in front of it. Each element is measured against its own box and simply not rendered below the threshold, because a cell showing only three dots costs ink and tells you nothing. The name it dropped is still on the native tooltip, in the focus announcement, in the readout line and in the data table.
- Texture tier — the palette holds five hues, so a sixth band would be painted exactly like the first. Every fifth band switches to the next texture — flat, stripes, crosshatch — which keeps fifteen bands apart and, unlike hue, survives a greyscale printout and colour vision deficiency. The legend swatch shows both channels together.
- Share axis, not value axis — every column is drawn to the full height, so the left gutter reads 0 to 100 percent of that column, whatever the column is worth. That normalisation is what lets a 3% region and a 34% region be compared on mix at all; the size difference is carried entirely by the width.
- Roving tab stop — the whole mosaic is one tab stop and the arrow keys walk it, so a twelve by eight chart costs the keyboard one stop instead of ninety-six. Navigation walks to the next painted cell, because a band worth zero has no tile to land on, and every landing updates the same readout line the pointer feeds.
Chart Scatter Matrix
A SPLOM whose panels share one domain per variable, brushes a record across every panel at once, puts distributions on the diagonal and Pearson r on the upper triangle, and states its panel cap instead of melting on 20 variables.
Dumbbell Chart
A four-state dumbbell chart — one row per category, two dots joined by a bar on one shared scale, ranked by valence-aware change with direction carried by shape, arrow and row order as well as colour.