Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, LayoutGrid } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildMarimekkoLayout,
  inspectMarimekkoData,
  type ChartMarimekkoData,
  type MarimekkoCell,
} from "./chart-marimekko.contract"

export interface ChartMarimekkoProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-marimekko.json

Prompt

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 proportional flag 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.

On This Page