Skip to content
UI Kit
Accent color

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

5 variants

Variants

Squarified

One level of tiles close to square, the largest in the accent, labels only where they fit.

Open in Studio

Grouped

Tiles inside four named groups with their totals, each group in its own tone, the legend carries the sums.

Open in Studio

Drill down

The groups first. A tap opens one and its tiles glide in, the trail above and Escape lead back.

Open in Studio

Tinted by change

Area is the size, the tint is the change against the last month. Arrows and signs carry the direction too, not the color alone.

Open in Studio

Two periods

A switch between two years. The tiles keep their order and glide to their new size, the readout names both values.

Open in Studio

Details

What Treemap brings along, read from the registry and the source.

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

Props

The TreemapProps interface from components/ui/treemap.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    TreemapVariant

    squarifiedgroupeddrilldownchangecompare

  • itemsrequired
    TreemapItem[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }

  • periodsoptional
    [string, string]

    compare: names of the two periods, the current first

  • highlightoptional
    string

    squarified: the tile in the accent at the start. Default the largest.

  • heightoptional
    number

    Height 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
totalEnglish Total {v}German Gesamt {v}
hintEnglish Open a group to see its partsGerman Eine Gruppe öffnen, um ihre Teile zu sehen
shareEnglish {share} of the totalGerman {share} vom Ganzen
shareColEnglish ShareGerman Anteil
allEnglish AllGerman Alle
backEnglish Back to allGerman Zurück zu allen
openEnglish Open {label}German {label} öffnen
periodsEnglish PeriodGerman Zeitraum
changeEnglish ChangeGerman Veränderung
itemEnglish ItemGerman Eintrag
groupEnglish GroupGerman Gruppe
valueEnglish ValueGerman Wert
gainEnglish GainGerman Plus
lossEnglish LossGerman Minus
flatEnglish About the sameGerman Etwa gleich
vsEnglish 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
components/ui/treemap.tsx
'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.

Often searched

Jump to

Copied to the clipboard