Treemap
Part to whole as tiles whose area is the value, flat, in groups, with drill down, tinted by change or across two periods with gliding tiles. Arrow keys move between tiles.
One of 4 components in Statistical charts
Variants
Details
What Treemap brings along, read from the registry and the source.
- Category
- Statistical charts
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/treemap.tsx
- Brings along
- utilskiticonchartchart-figure
- npm packages
- None
- Languages
- English and German, 16 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TreemapProps interface from components/ui/treemap.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalTreemapVariantsquarifiedgroupeddrilldownchangecompare
itemsrequiredTreemapItem[]formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
periodsoptional[string, string]compare: names of the two periods, the current first
highlightoptionalstringsquarified: the tile in the accent at the start. Default the largest.
heightoptionalnumberHeight of the box in px. Default: about 56 % of the width, taller on phones.
Built-in texts
Treemap writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 16 texts
| Key | English | German |
|---|---|---|
| total | English Total {v} | German Gesamt {v} |
| hint | English Open a group to see its parts | German Eine Gruppe öffnen, um ihre Teile zu sehen |
| share | English {share} of the total | German {share} vom Ganzen |
| shareCol | English Share | German Anteil |
| all | English All | German Alle |
| back | English Back to all | German Zurück zu allen |
| open | English Open {label} | German {label} öffnen |
| periods | English Period | German Zeitraum |
| change | English Change | German Veränderung |
| item | English Item | German Eintrag |
| group | English Group | German Gruppe |
| value | English Value | German Wert |
| gain | English Gain | German Plus |
| loss | English Loss | German Minus |
| flat | English About the same | German Etwa gleich |
| vs | English vs. {period} | German ggü. {period} |
Accessibility
Read from the source of the component.
- Treemap handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-current, aria-haspopup, aria-label, aria-live and aria-roledescription, so screen readers announce purpose and state.
- Uses the roles group, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 471 lines
'use client';
import { useMemo, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CHART_TEXTS, ChartRoot, ChartTable, fill, numberFormatter, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Part to whole as tiles whose area is the value.
* squarified one level, the picked tile in the accent
* grouped tiles inside up to four named groups
* drilldown the groups first, a tap opens one, the trail leads back
* change area is the size, tint is the change (gain or loss)
* compare two periods, the tiles glide to their new size
*
* Tiles are HTML buttons in one positioned box: they wrap text, take the
* keyboard (arrow keys move to the nearest tile in that direction) and
* glide when the layout changes. A 2 px gap in the surface color parts them.
* Labels only show where they fit, every value is in the tooltip and table.
*/
export type TreemapVariant = 'squarified' | 'grouped' | 'drilldown' | 'change' | 'compare';
export interface TreemapItem {
label: string;
/** Size of the tile */
value: number;
/** change: change against the last period as a fraction, 0.12 is +12 % */
change?: number;
/** compare: the value of the second period */
previous?: number;
/** grouped, drilldown: the tiles inside this group (value of the group is their sum) */
children?: TreemapItem[];
}
export interface TreemapProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: TreemapVariant;
items: TreemapItem[];
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** compare: names of the two periods, the current first */
periods?: [string, string];
/** squarified: the tile in the accent at the start. Default the largest. */
highlight?: string;
/** Height of the box in px. Default: about 56 % of the width, taller on phones. */
height?: number;
className?: string;
}
const TEXTS = {
en: { total: 'Total {v}', hint: 'Open a group to see its parts', share: '{share} of the total', shareCol: 'Share', all: 'All', back: 'Back to all', open: 'Open {label}', periods: 'Period', change: 'Change', item: 'Item', group: 'Group', value: 'Value', gain: 'Gain', loss: 'Loss', flat: 'About the same', vs: 'vs. {period}' },
de: { total: 'Gesamt {v}', hint: 'Eine Gruppe öffnen, um ihre Teile zu sehen', share: '{share} vom Ganzen', shareCol: 'Anteil', all: 'Alle', back: 'Zurück zu allen', open: '{label} öffnen', periods: 'Zeitraum', change: 'Veränderung', item: 'Eintrag', group: 'Gruppe', value: 'Wert', gain: 'Plus', loss: 'Minus', flat: 'Etwa gleich', vs: 'ggü. {period}' },
};
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Squarified treemap (Bruls, Huizing, van Wijk): rows that keep tiles close to square */
export function squarify(values: number[], box: Rect): Rect[] {
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
const out: Rect[] = new Array(values.length);
if (!total || box.w <= 0 || box.h <= 0) return values.map(() => ({ x: box.x, y: box.y, w: 0, h: 0 }));
const order = values.map((v, i) => ({ v: (Math.max(0, v) / total) * box.w * box.h, i })).sort((a, b) => b.v - a.v);
let { x, y, w, h } = box;
let row: { v: number; i: number }[] = [];
const worst = (r: { v: number }[], side: number) => {
const s = r.reduce((a, t) => a + t.v, 0);
const max = Math.max(...r.map((t) => t.v));
const min = Math.min(...r.map((t) => t.v));
return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
};
const layout = (r: { v: number; i: number }[]) => {
const s = r.reduce((a, t) => a + t.v, 0);
if (w >= h) {
const cw = s / h;
let cy = y;
for (const t of r) {
const th = t.v / cw;
out[t.i] = { x, y: cy, w: cw, h: th };
cy += th;
}
x += cw;
w -= cw;
} else {
const rh = s / w;
let cx = x;
for (const t of r) {
const tw = t.v / rh;
out[t.i] = { x: cx, y, w: tw, h: rh };
cx += tw;
}
y += rh;
h -= rh;
}
};
for (const t of order) {
const side = Math.min(w, h);
if (!row.length || worst([...row, t], side) <= worst(row, side)) row.push(t);
else {
layout(row);
row = [t];
}
}
if (row.length) layout(row);
return out;
}
const GAP = 2;
const sumOf = (it: TreemapItem): number => (it.children?.length ? it.children.reduce((a, c) => a + sumOf(c), 0) : it.value);
interface Tile {
key: string;
label: string;
value: number;
rect: Rect;
fill: string;
hover: string;
text: string;
group?: string;
groupIndex?: number;
change?: number;
previous?: number;
/** drilldown: opens this group */
opens?: number;
}
/**
* Tiles whose area is the value: flat, in groups, with drill down, tinted by change or across two periods.
* <Treemap variant="grouped" items={[{ label: 'Schools', value: 0, children: [{ label: 'Primary', value: 4.2 }] }]} />
*/
export function Treemap(props: TreemapProps) {
const { variant = 'squarified', items, format, periods, highlight, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const ct = useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
const signed = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + pct(Math.abs(n));
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<string | null>(null);
// One tab stop for all tiles, the arrow keys move between them
const [focusKey, setFocusKey] = useState<string | null>(null);
const [open, setOpen] = useState<number | null>(null);
const [period, setPeriod] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
const phone = width < 520;
const height = heightProp ?? Math.round(Math.max(300, Math.min(520, phone ? width * 1.15 : width * 0.56)));
const box: Rect = { x: 0, y: 0, w: width, h: height };
const name = label ?? title ?? '';
const tiles: Tile[] = useMemo(() => {
const flat = (list: TreemapItem[], b: Rect, mk: (it: TreemapItem, i: number, r: Rect) => Tile) => {
const rects = squarify(list.map((it) => sumOf(it)), b);
return list.map((it, i) => mk(it, i, rects[i]));
};
if (variant === 'grouped' || (variant === 'drilldown' && open === null)) {
const groups = squarify(items.map(sumOf), box);
if (variant === 'drilldown')
return items.map((g, gi) => ({
key: g.label,
label: g.label,
value: sumOf(g),
rect: groups[gi],
fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))',
hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))',
text: 'var(--foreground)',
opens: gi,
}));
return items.flatMap((g, gi) => {
const r = groups[gi];
const head = r.h > 64 && r.w > 70 ? 24 : 0;
const inner: Rect = { x: r.x + 6, y: r.y + head + 6, w: Math.max(0, r.w - 12), h: Math.max(0, r.h - head - 12) };
const color = pairColor(gi);
const kids = g.children?.length ? g.children : [g];
const groupTile: Tile = { key: `__g${gi}`, label: g.label, value: sumOf(g), rect: r, fill: `color-mix(in oklab, ${color} 9%, var(--card))`, hover: `color-mix(in oklab, ${color} 9%, var(--card))`, text: 'var(--foreground)', groupIndex: gi };
return [groupTile, ...flat(kids, inner, (it, i, rect) => ({ key: `${g.label}/${it.label}`, label: it.label, value: sumOf(it), rect, fill: `color-mix(in oklab, ${color} ${gi === 0 ? 30 : 26}%, var(--card))`, hover: `color-mix(in oklab, ${color} ${gi === 0 ? 46 : 40}%, var(--card))`, text: 'var(--foreground)', group: g.label, groupIndex: gi }))];
});
}
if (variant === 'drilldown' && open !== null) {
const g = items[open];
return flat(g?.children ?? [], box, (it, i, rect) => ({ key: `${g.label}/${it.label}`, label: it.label, value: sumOf(it), rect, fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))', hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))', text: 'var(--foreground)', group: g.label }));
}
if (variant === 'change')
return flat(items, box, (it, _i, rect) => {
const c = it.change ?? 0;
const tone = Math.abs(c) < 0.02 ? 'var(--muted-foreground)' : c > 0 ? 'var(--kitfg-up)' : 'var(--kitfg-down)';
const k = Math.abs(c) < 0.02 ? 12 : Math.abs(c) < 0.1 ? 20 : 34;
return { key: it.label, label: it.label, value: it.value, rect, fill: `color-mix(in oklab, ${tone} ${k}%, var(--card))`, hover: `color-mix(in oklab, ${tone} ${k + 12}%, var(--card))`, text: 'var(--foreground)', change: c };
});
if (variant === 'compare') {
// Same order in both periods, so tiles keep their neighbours and only glide
const vals = items.map((it) => (period === 0 ? it.value : it.previous ?? it.value));
const rects = squarify(vals, box);
return items.map((it, i) => ({ key: it.label, label: it.label, value: vals[i], rect: rects[i], fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))', hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))', text: 'var(--foreground)', previous: period === 0 ? it.previous : it.value }));
}
const top = highlight ?? [...items].sort((a, b) => sumOf(b) - sumOf(a))[0]?.label;
return flat(items, box, (it, _i, rect) => ({ key: it.label, label: it.label, value: sumOf(it), rect, fill: it.label === top ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 18%, var(--card))', hover: it.label === top ? 'color-mix(in oklab, var(--primary) 88%, var(--foreground))' : 'color-mix(in oklab, var(--primary) 30%, var(--card))', text: it.label === top ? 'var(--primary-foreground)' : 'var(--foreground)' }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variant, items, width, height, open, period, highlight]);
const leaves = tiles.filter((x) => !x.key.startsWith('__g'));
const total = leaves.reduce((a, x) => a + x.value, 0);
const activeTile = leaves.find((x) => x.key === active) ?? null;
// Keyboard: arrows go to the nearest tile in that direction
const move = (from: Tile, key: string) => {
const c = { x: from.rect.x + from.rect.w / 2, y: from.rect.y + from.rect.h / 2 };
let best: Tile | null = null;
let score = Infinity;
for (const o of leaves) {
if (o === from) continue;
const d = { x: o.rect.x + o.rect.w / 2 - c.x, y: o.rect.y + o.rect.h / 2 - c.y };
const ok = key === 'ArrowRight' ? d.x > 1 : key === 'ArrowLeft' ? d.x < -1 : key === 'ArrowDown' ? d.y > 1 : d.y < -1;
if (!ok) continue;
const along = key === 'ArrowRight' || key === 'ArrowLeft' ? Math.abs(d.x) : Math.abs(d.y);
const across = key === 'ArrowRight' || key === 'ArrowLeft' ? Math.abs(d.y) : Math.abs(d.x);
const s = along + across * 2;
if (s < score) {
score = s;
best = o;
}
}
return best;
};
const onTileKey = (e: React.KeyboardEvent, tile: Tile) => {
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Escape'].includes(e.key)) return;
e.preventDefault();
if (e.key === 'Escape') {
if (variant === 'drilldown' && open !== null) setOpen(null);
return;
}
const next = move(tile, e.key);
if (next) boxRef.current?.querySelector<HTMLElement>(`[data-tile="${CSS.escape(next.key)}"]`)?.focus();
};
const tableRows: string[][] =
variant === 'grouped' || variant === 'drilldown'
? items.flatMap((g) => (g.children?.length ? g.children : [g]).map((c) => [c.label, g.label, fmt(sumOf(c)), pct(sumOf(c) / Math.max(1, items.reduce((a, x) => a + sumOf(x), 0)))]))
: variant === 'change'
? items.map((it) => [it.label, fmt(it.value), signed(it.change ?? 0)])
: variant === 'compare'
? items.map((it) => [it.label, fmt(it.value), fmt(it.previous ?? it.value)])
: items.map((it) => [it.label, fmt(sumOf(it)), pct(sumOf(it) / Math.max(1, total))]);
const tableHead =
variant === 'grouped' || variant === 'drilldown'
? [t.item, t.group, t.value, t.shareCol]
: variant === 'change'
? [t.item, t.value, t.change]
: variant === 'compare'
? [t.item, periods?.[0] ?? '', periods?.[1] ?? '']
: [t.item, t.value, t.shareCol];
const tipFor = (x: Tile) => {
const lines = [fmt(x.value), fill(t.share, { share: pct(x.value / Math.max(1, total)) })];
if (x.change !== undefined) lines.push(`${t.change} ${signed(x.change)}`);
if (x.previous !== undefined && periods) lines.push(`${fill(t.vs, { period: periods[period === 0 ? 1 : 0] })} ${fmt(x.previous)}`);
return lines;
};
const actions =
variant === 'compare' && periods ? (
<div role="radiogroup" aria-label={t.periods} className="inline-flex h-9 items-center rounded-md bg-muted p-[3px] shadow-[inset_0_0_0_1px_var(--border)]">
{periods.map((p, i) => (
<button
key={p}
type="button"
role="radio"
aria-checked={period === i}
tabIndex={period === i ? 0 : -1}
onClick={() => setPeriod(i)}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
e.preventDefault();
const k = (period + step + periods.length) % periods.length;
setPeriod(k);
(e.currentTarget.parentElement?.children[k] as HTMLElement | undefined)?.focus();
}}
className={cn("relative h-full rounded-[5px] px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-300 after:absolute after:-inset-y-[7px] after:inset-x-0 after:content-['']", period === i ? 'bg-background text-foreground shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]' : 'text-muted-foreground hover:text-foreground')}
>
{p}
</button>
))}
</div>
) : undefined;
const legend =
variant === 'change' ? (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{[
{ label: t.gain, tone: 'var(--kitfg-up)', icon: 'arrow-up-right' as const },
{ label: t.flat, tone: 'var(--muted-foreground)', icon: null },
{ label: t.loss, tone: 'var(--kitfg-down)', icon: 'arrow-down-right' as const },
].map((k) => (
<li key={k.label} className="flex items-center gap-1.5">
<span aria-hidden className="flex h-3 overflow-hidden rounded-[3px]">
<span className="w-2.5" style={{ background: `color-mix(in oklab, ${k.tone} 20%, var(--card))` }} />
{k.icon && <span className="w-2.5" style={{ background: `color-mix(in oklab, ${k.tone} 34%, var(--card))` }} />}
</span>
{k.icon && <Icon name={k.icon} size={13} strokeWidth={2} />}
{k.label}
</li>
))}
</ul>
) : variant === 'grouped' ? (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{items.map((g, gi) => (
<li key={g.label} className="flex items-center gap-1.5">
<span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: `color-mix(in oklab, ${pairColor(gi)} 40%, var(--card))` }} />
{g.label}
<span className="tabular-nums text-foreground">{fmt(sumOf(g))}</span>
</li>
))}
</ul>
) : null;
const tileDelay = (i: number) => (anim ? ({ '--kitch-d': `${round(80 + i * 45, 0)}ms` } as React.CSSProperties) : undefined);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={actions} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
{variant === 'drilldown' && (
<nav aria-label={name} className="mb-3 flex min-h-11 flex-wrap items-center gap-1 text-[13px]">
<button type="button" onClick={() => setOpen(null)} disabled={open === null} className={cn('relative inline-flex min-h-11 items-center gap-1 rounded-md px-2 font-medium transition-colors sm:min-h-9', open === null ? 'text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')} aria-label={open === null ? undefined : t.back}>
{open !== null && <Icon name="arrow-left" size={15} strokeWidth={1.8} />}
{t.all}
</button>
{open === null && <span className="px-1 text-muted-foreground">{t.hint}</span>}
{open !== null && (
<>
<Icon name="chevron-right" size={14} className="text-subtle-foreground" />
<span aria-current="page" className="px-2 font-medium text-foreground">
{items[open]?.label}
</span>
</>
)}
</nav>
)}
<div ref={wrapRef} className="relative">
<div ref={boxRef} role="group" aria-roledescription={ct.chart} aria-label={summary ? `${name}. ${summary}` : name} className="relative overflow-hidden rounded-lg" style={{ height }}>
{tiles.map((x, i) => {
const isGroup = x.key.startsWith('__g');
const r = x.rect;
const w = Math.max(0, r.w - GAP);
const h = Math.max(0, r.h - GAP);
// Text only where it fits whole: padding 12, label line 15 or 19, value line 16 or 26
const pad = 24;
const valueText = fmt(x.value);
const big = w > 170 && h > 110 && textWidth(x.label, 15) <= w - pad && textWidth(valueText, 20) <= w - pad;
const oneLine = textWidth(x.label, big ? 15 : 12) <= w - pad;
// A label too long for one line may take two, when every word fits and the tile is tall enough
const words = x.label.split(' ');
const twoLines = !oneLine && !big && words.length > 1 && words.every((wd) => textWidth(wd, 12) <= w - pad) && (() => {
let line = '';
let count = 1;
for (const wd of words) {
const next = line ? `${line} ${wd}` : wd;
if (textWidth(next, 12) > w - pad) {
count++;
line = wd;
} else line = next;
}
return count <= 2;
})() && h >= pad + 32;
const showLabel = (oneLine && h >= pad + (big ? 19 : 15)) || twoLines;
const showValue = showLabel && textWidth(valueText, big ? 20 : 12) + (x.change !== undefined ? 64 : 0) <= w - pad && h >= pad + (big ? 19 + 6 + 26 : (twoLines ? 32 : 15) + 4 + 16);
const style: React.CSSProperties = {
left: round(r.x),
top: round(r.y),
width: round(w),
height: round(h),
background: active === x.key ? x.hover : x.fill,
color: x.text,
transition: "left .7s var(--ease-kit), top .7s var(--ease-kit), width .7s var(--ease-kit), height .7s var(--ease-kit), background-color .2s var(--ease-kit)",
...tileDelay(i),
};
if (isGroup)
return (
<div key={x.key} aria-hidden className={cn('absolute rounded-md transition-[left,top,width,height] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', anim && 'kitch-fade')} style={style}>
{r.h > 64 && r.w > 70 && (
<p className="flex items-baseline justify-between gap-2 truncate px-2.5 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
<span className="truncate">{x.label}</span>
<span className="tabular-nums normal-case tracking-normal">{fmt(x.value)}</span>
</p>
)}
</div>
);
const tip = tipFor(x);
return (
<button
key={x.key}
type="button"
data-tile={x.key}
aria-label={`${x.label}, ${tip.join(', ')}`}
aria-haspopup={x.opens !== undefined ? 'true' : undefined}
onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(x.key)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
tabIndex={(tiles.some((y) => y.key === focusKey) ? focusKey : tiles[0]?.key) === x.key ? 0 : -1}
onFocus={() => {
setActive(x.key);
setFocusKey(x.key);
}}
onBlur={() => setActive(null)}
onKeyDown={(e) => onTileKey(e, x)}
onClick={() => {
if (x.opens !== undefined) {
setOpen(x.opens);
setActive(null);
requestAnimationFrame(() => boxRef.current?.querySelector<HTMLElement>('[data-tile]')?.focus());
} else setActive(active === x.key ? null : x.key);
}}
className={cn(
'absolute flex flex-col items-start justify-between overflow-hidden rounded-md p-2.5 text-left outline-offset-[-2px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground motion-reduce:!transition-none sm:p-3',
anim && 'kitch-cell',
x.opens !== undefined && 'cursor-zoom-in',
)}
style={style}
>
{showLabel && (
<span className={cn('block max-w-full font-medium leading-tight', big ? 'text-[15px]' : 'text-[12px]', !big && !twoLines && 'truncate')}>
{x.label}
</span>
)}
{showValue && (
<span className="flex w-full flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span className={cn('tabular-nums', big ? 'text-[20px] font-semibold tracking-[-0.01em]' : 'text-[12px] opacity-80')}>{fmt(x.value)}</span>
{x.change !== undefined && (
<span className="inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums">
{Math.abs(x.change) >= 0.02 && <Icon name={x.change > 0 ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />}
{signed(x.change)}
</span>
)}
{x.opens !== undefined && big && <Icon name="zoom-in" size={16} className="opacity-60" />}
</span>
)}
</button>
);
})}
</div>
{/* Readout under the box: works for every tile, also the ones too small for a label */}
<p aria-live="polite" className="mt-3 flex min-h-5 flex-wrap items-baseline gap-x-2 text-[13px]">
{activeTile ? (
<>
<span className="font-medium text-foreground">{activeTile.label}</span>{' '}
{activeTile.group && <span className="text-muted-foreground">{activeTile.group} </span>}
<span className="tabular-nums text-foreground">{fmt(activeTile.value)}</span>{' '}
<span className="tabular-nums text-muted-foreground">{fill(t.share, { share: pct(activeTile.value / Math.max(1, total)) })} </span>
{activeTile.change !== undefined && <span className="tabular-nums text-muted-foreground">{`${t.change} ${signed(activeTile.change)}`}</span>}
{activeTile.previous !== undefined && periods && <span className="tabular-nums text-muted-foreground">{`${fill(t.vs, { period: periods[period === 0 ? 1 : 0] })} ${fmt(activeTile.previous)}`}</span>}
</>
) : (
<span className="text-muted-foreground">{variant === 'compare' && periods ? `${periods[period]} · ${fill(t.total, { v: fmt(total) })}` : fill(t.total, { v: fmt(total) })}</span>
)}
</p>
</div>
<ChartTable caption={name} head={tableHead} rows={tableRows} />
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Treemap?
With aiw add @ai-website/treemap. The command puts components/ui/treemap.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.
Which variants does Treemap have?
5 variants, Squarified, Grouped, Drill down, Tinted by change and Two periods. You pick one with the variant prop, for example variant="grouped".
Can I translate the texts of Treemap?
Yes. Treemap carries 16 texts of its own in English and German, such as “Open a group to see its parts”, “Share” and “All”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Treemap?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Treemap with aiw_components and install it with aiw_add @ai-website/treemap. Name the variant in your prompt, for example variant="squarified", and the assistant writes your content straight into the props.
Can I adjust Treemap without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Treemap code to copy.