Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import {
  buildIcicleTree,
  walkIcicleTree,
  type ChartIcicleData,
  type IcicleTreeNode,
} from "./chart-icicle.contract"

export interface ChartIcicleProps

Installation

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

Prompt

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 a du run 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 direction keeps every key meaning the same movement on screen.

On This Page