Icicle
A four-state linear-partition hierarchy chart in DOM blocks — one level per row or column, length proportional to value, click-to-drill with a breadcrumb, honest label truncation with tooltips, and an sr-only breakdown table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-icicle.jsonPrompt
Build a React + TypeScript + Tailwind "ChartIcicle" card — a linear partition of
a hierarchy, one level per row or column — out of absolutely positioned DOM
blocks (no charting library and no SVG), with zod for the contract and
lucide-react for the two state icons.
Contract
- One zod schema is the source of truth, and the component's props are z.infer
of it plus the render knobs — never a parallel hand-written interface:
{ status: "loading" | "empty" | "error" | "ready";
title: string; rootLabel?: string; unit?: string;
entries: { path: string[]; value: number }[] }
- The payload is a list of PATHS, not a nested tree and not rows with parent
pointers, because that is what the sources of an icicle actually emit: a
folded stack sample, a `du` listing, a URL breakdown, a GROUP BY with the
grouping columns in order. Two properties fall out of it for free and are
worth stating in the JSDoc: a path cannot loop, so there is no cycle to
detect and no orphan to report; and two entries carrying the same path are
the same node measured twice, so they SUM — which is exactly what a sampling
profiler produces.
- `value` is charged to the LAST segment of `path`, and to that segment only. A
node's total is its own charge plus everything inside it. A charge on a
segment that also has children is legal and meaningful — self time, bytes in
a folder that are in no sub-folder — and it is drawn as the gap left at the
end of that node's child row. Never fold it into a child and never drop it.
- Path segments are the labels, so `path: z.array(z.string().min(1)).min(1)`;
an empty segment or an empty path is a parse error, and `ready` requires at
least one entry.
- Ship buildIcicleTree(entries, { rootLabel, order }) beside the schema. It
cannot fail structurally, so it returns { root, stats } rather than a result
union. Each node carries key (the percent-encoded path, collision-free even
when a segment contains the separator), label, path, depth, own, total,
shareOfParent, shareOfTotal, height, leafCount, children. `stats` carries how
many entries were dropped for being negative or zero, how much sits on nodes
that also have children, and how many nodes that is.
- Extra props: description, direction ("down" | "right", default "down"),
maxLevels (default 4, clamped 1-8), rowHeight (default 30, clamped 16-96,
"down" only), height (default 260, clamped 120-800, "right" only),
labelMinWidth (default 40), order ("value" | "input"), locale (default
"en-US" — Intl.*(undefined) desyncs SSR from the visitor), formatValue,
onSelect, onDrill, onRetry, className, plus forwardRef and the rest of the
native div props spread on the root.
Behavior
- LAYOUT. A child's length is its parent's length times its share of the
parent. Compute every boundary from a RUNNING SUM of child totals rather than
by adding lengths one at a time, so float error cannot accumulate along a
level. Sort siblings in the same pass that sums them: summing the same floats
in two different orders gives two slightly different totals, and the child row
would then miss its parent's edge by a fraction of a pixel.
- SELF TIME IS A GAP, not a rounding error. When a node's children sum to less
than its total, the leftover is left unpainted at the end of its child row and
named in the footnote. Stretching the children over it — the obvious "make it
fill" fix — silently reassigns a group's own time to its callees.
- NO MINIMUM BLOCK SIZE, deliberately. Length given to one block has to come out
of a sibling, and a level that no longer sums to its parent has stopped being
a partition. So a 0.03% entry is drawn at its true sub-pixel width, and it is
still focusable, still in the tooltip, still in the readout, still in the
table. Count those and say so in the footnote instead of hiding them.
- LABELS, and the honest handling of the ones that do not fit. Every block gets
a native `title` and an aria-label carrying the full name and the numbers,
always. Text is printed only when the block is at least labelMinWidth along
the reading direction and tall enough for one line; the printed name is
truncated by CSS with an ellipsis, so the browser measures it — a hand-rolled
character-budget estimator is wrong by about a third either way depending on
the case of the string. The figure is printed only when there is room for it
WHOLE: a truncated number reads as a different number, while a missing one
sends the reader to the tooltip and the table, which carry it in full. The
footnote states how many blocks carry no label at all.
- THE PADDING TRAP, which is the whole reason the block markup has two layers.
Side padding is a floor on a box's used width: a 1px block that still rendered
a padded text layer would paint 8px wide and cover its neighbours, so the
partition stops being exact exactly where exactness is hardest to see. Render
the padded text layer only when there is text to put in it.
- DRILL. Clicking a block that has children makes it the whole extent; a leaf
click pins the readout and fires onSelect (clicking again unpins). The drill
path is stored as segments and RE-RESOLVED against the current tree on every
render, so swapping `entries` can never strand the view inside a subtree that
no longer exists — the chain stops at the last segment that still resolves,
then backs out of anything that has since become a leaf. A breadcrumb walks
back; every crumb except the last is a real button. Guard the click with the
event's own `detail` counter: the second click of a double lands on whatever
the re-render moved under the pointer.
- FOCUS AFTER DRILL. Every drill unmounts the block that was activated, and a
breadcrumb jump unmounts the crumb that just became the current one, so focus
would fall to `body`. Move it to the first block of the new view, keyed on a
drill COUNTER rather than on the block array — the block array is also rebuilt
on a resize, and keying off it would let a window drag steal focus.
- SIZING. Geometry is pure percentages, so nothing has to be measured to draw
the chart; one ResizeObserver supplies the width only for the label budget,
rAF-deferred (writing state straight from the callback re-enters layout in the
same frame), sub-0.5px deltas ignored, cancelled and disconnected on unmount,
and the observe() call wrapped in try/catch. Until the first measurement
arrives the budget falls back to an assumed width, so the server render is a
complete chart rather than a blank box.
- FOUR STATES are first-class branches of one bg-card panel: pulsing skeleton
rows in the same geometry (aria-hidden, plus one sr-only status line, no
timers) for loading; a zero-data panel for empty; an error panel with a Try
again button only when onRetry exists. A fifth outcome is reachable from
`ready` and needs its own sentence: parsed fine, but nothing survived with
positive length.
- DEGENERATE DATA, each handled on purpose: a single path (the first row is one
block at 100% and size encodes nothing); duplicate paths (they sum); negative
values, which cannot have length and are DROPPED AND COUNTED rather than
folded in as their absolute value; zeros and non-finite values, dropped the
same way; a tree deeper than the level budget, whose deepest drawn blocks get
a dashed far edge and still drill in; segments longer than their block.
- CLEANUP: one ResizeObserver and one focus effect. No timers, no rAF loops, no
window listeners; element references live in a Map written by ref callbacks,
so unmount removes them through the same callback that added them.
Rendering & styling
- Semantic tokens only: var(--chart-1..5) for the branches, --card,
--foreground, --muted, --muted-foreground, --ring, --destructive, --border.
Zero hex, zero rgb(), zero invented hues. cn() merges every className.
- COLOUR. Each top-level branch takes one --chart-N slot, cycling after five,
and every descendant keeps its branch's token and gets PALER with depth:
color-mix(in oklab, var(--chart-N) S%, var(--card)) with S falling
42 - 34 - 27 - 21 - 17 - 14 - 12 - 10 across the levels. The mix is toward
--card, the opposite of what a sunburst wants, because unlike a sunburst every
block here carries text: landing the fill on the opaque --card makes the
contrast deterministic whatever sits behind the chart. The 42% ceiling is a
measured budget, not taste — measured through a canvas readback, the worst of
the five tokens carries text-foreground at 7.40:1 in the light theme and
6.85:1 in the dark one, and AA's 4.5:1 runs out around 55% in dark. Raise it
and the strongest blocks silently stop being readable.
- COLOUR IS NEVER THE ONLY CHANNEL: level position encodes depth, position along
the axis encodes rank, every block is parted from its neighbour by a 1px gap
of --card, blocks are labelled wherever they fit, and the sr-only table
carries every exact number.
- Blocks are absolutely positioned DOM buttons, never canvas and never SVG:
every block is then focusable, has real text that the browser truncates, gets
a native tooltip for free, and needs no glyph-width estimation.
- A block holding levels past the budget gets a dashed far edge — texture, not
hue — and still opens on click.
- Hover, focus and the pin share ring-inset rings so a ring never bleeds over
the neighbour it was just parted from.
- No entrance animation anywhere, so prefers-reduced-motion has nothing to
disable; the only animation is the loading pulse, and that carries
motion-reduce:animate-none.
- ACCESSIBILITY CONTRACT. The plot is role="group" with an aria-label naming
the view and stating the keyboard map, and aria-describedby pointing at an
sr-only summary that states the actual finding: total, block count, levels
drawn, largest and smallest branch with their shares, how many blocks are
unlabelled, how many hold hidden levels. Deliberately not role="tree":
nothing expands in place, so aria-expanded and aria-level would promise
semantics the widget does not have. One roving tab stop over the blocks, with
the arrows mapped SPATIALLY so the same key always means the same movement on
screen: in "down", Left/Right walk the level and Up/Down change level; in
"right", Up/Down walk the level and Left/Right change level. A level is a
segment, not a loop, so walking off its end STOPS — unlike a sunburst ring,
where wrapping is correct. Home/End jump to the ends, Enter and Space
activate natively, Backspace drills out one level, and preventDefault is
called for every key the widget claims so arrowing never scrolls the page.
Below the plot, 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 table of
every entry inside the current view with its parent, level, value, the part
charged to it directly, its share of its group and its share of the whole.
The visible readout line is aria-hidden, because the focused block already
announces all of it and a live region would say every word twice.
Customization levers
- direction: "down" for a wide card where the headline split matters most;
"right" when the tree is deep or the names are long, because a column keeps
its full width at every level while a row block gets narrower the deeper it
sits. rowHeight is the density knob for "down" (16-96; 44 is comfortable for
touch), height is the one for "right".
- maxLevels: 2 for a compact summary strip, 6-8 for an explorer. It is a
RENDERING budget, not a data filter — deeper levels stay reachable by
drilling and stay in the table either way.
- labelMinWidth: raise it to keep only the big blocks labelled and let the
small ones be pure colour, lower it for a denser, busier read. The
figure-printing thresholds beside it are what stop a number from being
truncated into a different number; move them together with your formatter.
- order: "input" whenever sibling position already carries meaning — severity,
price tier, calendar months. "value" (default) makes rank readable in
greyscale, which is worth more when the labels are arbitrary.
- Palette: re-point the five --chart-* slots and blocks, and the depth ramp
follows. Flatten or steepen the ramp by editing the eight-step array, or key
the colour off the leaf's own branch instead of the top-level one when
provenance matters more than grouping. Keep the top of the ramp at or under
about 50% or the labels lose AA.
- Dropping the long tail is a DATA decision, not a rendering one: group
everything under a threshold into one "other" path upstream, so the partition
still sums to the total. Do not add a minimum block size in the renderer.
- Wiring: onDrill to mirror the path into the URL or a side panel, onSelect to
open a detail drawer for a leaf, formatValue for bytes, currency or
durations. To make the chart read-only, drop the roving tabIndex and the
click handler and keep the table — the static picture is still complete.Concepts
- Linear partition — the family the chart belongs to. Depth is read from position on an axis (which row, which column), and magnitude from length along the other axis. That is the trade against its two siblings: a sunburst spends the same information on angle and radius, and a treemap spends both dimensions on magnitude. Spending one whole axis on depth is what buys straight, horizontal, readable labels for a deep tree.
- Paths as the payload — the hierarchy arrives as
{ path, value }rows rather than a nested object or parent pointers. A path cannot loop and cannot dangle, so there is no structural error class at all; and repeated paths sum, which is precisely what a sampling profiler or adurun emits. - Self time is a gap — a charge written on a node that also has children is real, and it shows as unpainted space at the end of that node's child row. Stretching the children to fill it is the tempting fix and the wrong one: it silently reassigns a group's own cost to the things inside it.
- Exact tiling from running sums — boundaries are computed from a cumulative total rather than by adding lengths one at a time, and siblings are sorted in the same pass that sums them. Sum the same floats in two orders and the two totals differ, which is enough to make a child row miss its parent's edge.
- No minimum block size — length given to one block is length taken from a sibling, so a floor would break the guarantee that a level sums to its parent. Sub-pixel blocks stay sub-pixel on purpose, stay focusable, and are counted in the footnote.
- Truncate with a tell, never a silent cut — names are cut by CSS with an ellipsis, so the browser measures the text instead of a hand-rolled estimator; the full name lives in the tooltip, the accessible name and the table. Figures are held to a stricter rule and printed only when they fit whole, because a truncated number is a different number.
- Padding is a width floor — a padded text layer cannot be narrower than its own padding, so rendering one inside a 1px block makes that block paint 8px wide and cover its neighbours. The text layer exists only when there is text, which is why the block is two elements rather than one.
- A level is a segment, not a loop — arrow keys walk a level and stop at its ends, where a sunburst ring wraps. The arrow mapping is spatial, so flipping
directionkeeps every key meaning the same movement on screen.
Lollipop Chart
A four-state lollipop chart — one hairline and one dot per category on a shared scale, ranked, with an optional reference baseline and a second dimension carried by dot size or shade.
Horizon
A four-state horizon chart that folds each series into stacked bands and encodes magnitude as depth of colour, so a dozen dense time series fit in a screenful — one shared band scale, mirrored or split negatives, and a column cursor that reads every lane at the same instant.