A dependency-free treemap: each item is a rectangle sized by its share of the whole in a squarified layout, with real text on the cells, group colours and a legend with totals, and arrow keys that move to the nearest cell.
Model inference · $3,150 USD · 20% of the totalUp 18% on last month with the new assistant.
- Production$8,050 · 51%
- AI$4,320 · 27%
- Developer$1,200 · 8%
- Observability$2,190 · 14%
"use client"
import { Treemap, type TreemapItem } from "@/components/ballmac/treemap"
const items: TreemapItem[] = [
{ id: "compute", label: "Compute", value: 4210, group: "Production", detail: "Mostly the web tier and the batch workers." },
{ id: "db", label: "Database", value: 2380, group: "Production", detail: "Two replicas and point-in-time recovery." },
{ id: "cdn", label: "CDN", value: 1120, group: "Production" },
{ id: "queue", label: "Queues", value: 340, group: "Production" },
{ id: "ml", label: "Model inference", value: 3150, group: "AI", detail: "Up 18% on last month with the new assistant." },
{ id: "vec", label: "Vector store", value: 760, group: "AI" },
{ id: "embed", label: "Embeddings", value: 410, group: "AI" },
{ id: "ci", label: "CI runners", value: 920, group: "Developer" },
{ id: "reg", label: "Registry", value: 280, group: "Developer" },
{ id: "logs", label: "Logs", value: 1450, group: "Observability" },
{ id: "met", label: "Metrics", value: 530, group: "Observability" },
{ id: "tr", label: "Traces", value: 210, group: "Observability" },
]
export default function TreemapDemo() {
return (
<div className="w-full max-w-3xl">
<Treemap items={items} unit="USD" defaultSelected="ml" format={(v) => `$${v.toLocaleString("en-US")}`} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/treemapAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/direction @ballmac/i18nCopy the source into your project.
components/ballmac/treemap.tsx// Ballmac UI: Treemap. https://ui.ballmac.com/components/treemap "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useDirection } from "@/lib/ballmac/direction" import { useMessages } from "@/lib/ballmac/i18n" type TreemapItem = { id: string label: string /** The size, in any unit. Items with a value of zero or less are left out. */ value: number /** Items in the same group share a colour and a line in the legend. */ group?: string /** A line shown under the chart when the item is selected. */ detail?: string } type Rect = { x: number; y: number; w: number; h: number } /** The squarified layout (Bruls, Huizing and van Wijk): rows of items kept as close to square as the shape allows. Sizes are in the units of w and h. */ function squarify(items: { id: string; value: number }[], w: number, h: number): Record<string, Rect> { const total = items.reduce((n, i) => n + i.value, 0) const out: Record<string, Rect> = {} if (total <= 0 || w <= 0 || h <= 0) return out const scale = (w * h) / total const rest = [...items].sort((a, b) => b.value - a.value).map((i) => ({ id: i.id, area: i.value * scale })) let x = 0 let y = 0 let rw = w let rh = h let row: typeof rest = [] const worst = (r: typeof rest, side: number) => { const s = r.reduce((n, i) => n + i.area, 0) const max = Math.max(...r.map((i) => i.area)) const min = Math.min(...r.map((i) => i.area)) return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min)) } const place = (r: typeof rest) => { const s = r.reduce((n, i) => n + i.area, 0) if (rw >= rh) { const colW = s / rh let cy = y for (const i of r) { const ih = i.area / colW out[i.id] = { x, y: cy, w: colW, h: ih } cy += ih } x += colW rw -= colW } else { const rowH = s / rw let cx = x for (const i of r) { const iw = i.area / rowH out[i.id] = { x: cx, y, w: iw, h: rowH } cx += iw } y += rowH rh -= rowH } } while (rest.length) { const side = Math.min(rw, rh) const next = rest[0] if (row.length === 0 || worst([...row, next], side) <= worst(row, side)) { row.push(next) rest.shift() } else { place(row) row = [] } } if (row.length) place(row) return out } const TINTS = [1, 2, 3, 4, 5] type TreemapProps = Omit<React.ComponentProps<"div">, "onSelect"> & { items: TreemapItem[] /** Turns a value into text. */ format?: (value: number) => string /** Width divided by height of the chart. */ aspect?: number /** The selected item id. Controlled. */ selected?: string | null /** The selected item id for uncontrolled use. */ defaultSelected?: string | null /** Called when an item is chosen, or null when it is cleared. */ onSelectedChange?: (id: string | null) => void /** What the sizes mean, for assistive technology, for example "tokens". */ unit?: string } /** * A treemap: every item is a rectangle whose area is its share of the whole, laid out so the rectangles stay close to square. Cells are buttons with real * text (name, value and share), the arrow keys move to the nearest cell in that direction, and groups get a colour and a legend line with their total. */ function Treemap({ items, format = (v) => v.toLocaleString("en-US"), aspect = 16 / 9, selected: selectedProp, defaultSelected = null, onSelectedChange, unit, dir, className, ...props }: TreemapProps) { const msg = useMessages() const direction = useDirection(dir === "rtl" || dir === "ltr" ? dir : undefined) const [inner, setInner] = React.useState<string | null>(defaultSelected) const selected = selectedProp !== undefined ? selectedProp : inner const data = React.useMemo(() => items.filter((i) => i.value > 0), [items]) const total = data.reduce((n, i) => n + i.value, 0) const W = 100 * aspect const H = 100 const rects = React.useMemo(() => squarify(data, W, H), [data, W]) const groups = React.useMemo(() => { const list: string[] = [] for (const i of data) if (i.group && !list.includes(i.group)) list.push(i.group) return list }, [data]) const tint = (i: TreemapItem) => (i.group ? TINTS[groups.indexOf(i.group) % TINTS.length] : 1) const ordered = [...data].sort((a, b) => b.value - a.value) const [focus, setFocus] = React.useState<string | null>(null) const tabbable = (focus && data.some((i) => i.id === focus) ? focus : selected && data.some((i) => i.id === selected) ? selected : ordered[0]?.id) ?? null const refs = React.useRef(new Map<string, HTMLButtonElement>()) const chosen = data.find((i) => i.id === selected) function pick(id: string) { const next = id === selected ? null : id if (selectedProp === undefined) setInner(next) onSelectedChange?.(next) } function onKeyDown(e: React.KeyboardEvent, id: string) { const me = rects[id] if (!me) return const cx = me.x + me.w / 2 const cy = me.y + me.h / 2 const rtl = direction === "rtl" const dirs: Record<string, [number, number]> = { ArrowRight: [rtl ? -1 : 1, 0], ArrowLeft: [rtl ? 1 : -1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] } if (e.key === "Home" || e.key === "End") { e.preventDefault() const to = e.key === "Home" ? ordered[0] : ordered[ordered.length - 1] setFocus(to.id) refs.current.get(to.id)?.focus() return } const d = dirs[e.key] if (!d) return e.preventDefault() // The nearest cell whose centre lies in that direction, preferring ones that line up with this cell. let best: { id: string; score: number } | null = null for (const o of data) { if (o.id === id) continue const r = rects[o.id] const dx = r.x + r.w / 2 - cx const dy = r.y + r.h / 2 - cy const along = dx * d[0] + dy * d[1] if (along <= 0.001) continue const across = Math.abs(dx * d[1]) + Math.abs(dy * d[0]) const score = along + across * 2 if (!best || score < best.score) best = { id: o.id, score } } if (best) { setFocus(best.id) refs.current.get(best.id)?.focus() } } return ( <div data-slot="treemap" dir={dir} className={cn("grid w-full min-w-0 gap-3", className)} {...props}> <div className="relative w-full overflow-hidden rounded-xl border bg-card" style={{ aspectRatio: String(aspect) }} role="group" aria-label={msg("treemap.label", "Share of the total by item")}> {data.map((i) => { const r = rects[i.id] if (!r) return null const share = (i.value / total) * 100 const on = selected === i.id const big = (r.w * r.h) / (W * H) > 0.045 const mid = (r.w * r.h) / (W * H) > 0.015 return ( <button key={i.id} ref={(el) => { if (el) refs.current.set(i.id, el) else refs.current.delete(i.id) }} type="button" aria-pressed={on} tabIndex={tabbable === i.id ? 0 : -1} aria-label={msg("treemap.cell", "{label}: {value}{unit}, {share}% of the total{group}", { label: i.label, value: format(i.value), unit: unit ? ` ${unit}` : "", share: Math.round(share * 10) / 10, group: i.group ? `, ${i.group}` : "" })} onClick={() => pick(i.id)} onFocus={() => setFocus(i.id)} onKeyDown={(e) => onKeyDown(e, i.id)} className={cn("absolute flex flex-col items-start justify-between overflow-hidden rounded-md p-2 text-start text-foreground outline-none transition-shadow focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", on ? "z-10 shadow-[inset_0_0_0_2px_var(--foreground)]" : "hover:shadow-[inset_0_0_0_1.5px_var(--foreground)]")} style={{ insetInlineStart: `calc(${(r.x / W) * 100}% + 1.5px)`, top: `calc(${(r.y / H) * 100}% + 1.5px)`, width: `calc(${(r.w / W) * 100}% - 3px)`, height: `calc(${(r.h / H) * 100}% - 3px)`, backgroundColor: `color-mix(in oklab, var(--chart-${tint(i)}) 30%, var(--card))`, }} > {mid && <span className={cn("w-full truncate font-medium", big ? "text-sm" : "text-xs")}>{i.label}</span>} {big && ( <span className="text-xs tabular-nums text-foreground/80"> {format(i.value)} <span>· {Math.round(share)}%</span> </span> )} </button> ) })} </div> <div aria-live="polite" className="min-h-[2.5rem] text-sm"> {chosen ? ( <p> <span className="font-medium">{chosen.label}</span> <span className="text-muted-foreground tabular-nums">· {format(chosen.value)}{unit ? ` ${unit}` : ""} · {msg("treemap.share", "{percent}% of the total", { percent: Math.round((chosen.value / total) * 1000) / 10 })}</span> {chosen.detail && <span className="mt-0.5 block text-muted-foreground">{chosen.detail}</span>} </p> ) : ( <p className="text-muted-foreground">{msg("treemap.hint", "Select a block to see its share.")}</p> )} </div> {groups.length > 0 && ( <ul className="flex flex-wrap gap-x-5 gap-y-1.5 text-xs" aria-label={msg("treemap.groups", "Groups")}> {groups.map((g, gi) => { const sum = data.filter((i) => i.group === g).reduce((n, i) => n + i.value, 0) return ( <li key={g} className="flex items-center gap-2"> <span aria-hidden="true" className="size-3 rounded-sm border" style={{ backgroundColor: `color-mix(in oklab, var(--chart-${TINTS[gi % TINTS.length]}) 30%, var(--card))` }} /> <span className="font-medium">{g}</span> <span className="text-muted-foreground tabular-nums">{format(sum)} · {Math.round((sum / total) * 100)}%</span> </li> ) })} </ul> )} </div> ) } export { Treemap, squarify, type Rect, type TreemapItem, type TreemapProps }Update the import paths to match your project setup.
Usage
import { Treemap, squarify } from "@/components/ballmac/treemap"The full example is in the Code tab above.
Examples
What fills a context window
Select a block to see its share.
- Instructions8,800 · 7%
- History64,100 · 50%
- Files41,500 · 33%
- Retrieved13,000 · 10%
import { Treemap, type TreemapItem } from "@/components/ballmac/treemap"
const items: TreemapItem[] = [
{ id: "sys", label: "System prompt", value: 3200, group: "Instructions" },
{ id: "tools", label: "Tool definitions", value: 5600, group: "Instructions" },
{ id: "h1", label: "Earlier conversation", value: 41800, group: "History", detail: "Messages from before the last summary." },
{ id: "h2", label: "Recent turns", value: 22300, group: "History" },
{ id: "f1", label: "billing/export.ts", value: 14200, group: "Files" },
{ id: "f2", label: "billing/client.ts", value: 9100, group: "Files" },
{ id: "f3", label: "billing/*.test.ts", value: 11800, group: "Files" },
{ id: "f4", label: "docs/billing.md", value: 6400, group: "Files" },
{ id: "r1", label: "Search results", value: 8700, group: "Retrieved" },
{ id: "r2", label: "Ticket thread", value: 4300, group: "Retrieved" },
]
export default function TreemapContext() {
return (
<div className="w-full max-w-3xl">
<Treemap items={items} unit="tokens" aspect={2} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
items* | TreemapItem[] | — |
formatTurns a value into text. | (value: number) => string | (v) => v.toLocaleString("en-US") |
aspectWidth divided by height of the chart. | number | 16 / 9 |
selectedThe selected item id. Controlled. | string | null | — |
defaultSelectedThe selected item id for uncontrolled use. | string | null | null |
onSelectedChangeCalled when an item is chosen, or null when it is cleared. | (id: string | null) => void | — |
unitWhat the sizes mean, for assistive technology, for example "tokens". | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Enters the chart once, on the largest cell (or the selected one) |
| Arrow keys | Move to the nearest cell in that direction (left and right swap in right-to-left); Home and End go to the largest and smallest |
| Enter / Space | Selects the cell; again to clear it |
| Screen readers | Each cell reads 'Compute: 4,210 USD, 38.2% of the total, Production'; the selected item is a polite live region |
Use with AI
items is [{ id, label, value, group?, detail? }]; zero or negative values are dropped. format(value), unit, aspect (width over height, default 16/9), selected / defaultSelected / onSelectedChange. Exports squarify(items, w, h) for your own layouts. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Treemap (@ballmac/treemap) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing how a whole divides into many parts of very different size
- Disk, cost, token or traffic breakdowns
Not for
- A handful of parts; use chart-pie or a bar list
- A change over time; use chart-area
- Flows between steps; use flow-sankey
Registry JSON: https://ui.ballmac.com/r/treemap.json
Credits
Free to use in personal and commercial projects.
- npm
- None
Pairs well with
Token Meter
A context-window meter that stacks system, history and attachments against the limit with a reserved reply area, plus a composer pill that opens the same view in a popover.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Heat Grid
A matrix of values shaded by size, such as models against test cases or services against regions: a real ARIA grid with arrow-key navigation, value text in every cell, a tone mode with icons and words, hatched empty cells and a legend.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.