Pictogram chart
Counts as rows of small self-drawn figures. A field of 100 for shares, rows to compare with cut figures for fractions, stacked rows where a figure can belong to two parts and two fields side by side.
Counts people can picture
A pictogram chart shows counts as rows of figures, in the tradition of isotype. Shares and amounts become something people can picture, 46 litres read as four and a half drops, which makes the chart a natural fit for reports, campaigns and data stories.
Field of 100 colors a hundred figures by part in order with the rest pale, the key names each share large and the pointer brings a part forward. Rows to compare uses one figure per ten litres with the last one cut to its fraction. Stacked rows follows flats finished per year with the sizes one after another in a row, where two sizes meet inside a house, the house is split between them. Two fields sets two fields of 100 side by side, the earlier in grey and the later in the accent, with the difference in points below.
Use one figure that means the thing counted, a drop for water, a house for flats. Say in the key what one figure stands for. Keep counts small enough to see, a field of 100 is the sweet spot for shares.
Variants
Details
What Pictogram chart brings along, read from the registry and the source.
- Category
- Comparison charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/pictogram-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 8 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PictogramChartProps interface from components/ui/pictogram-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalPictogramVariantgridcomparestackedpair
itemsrequiredPictogramItem[]partsoptionalstring[]stacked: names of the parts (up to four)
iconoptionalPictogramIconpersonhousecupleafdropcarboxhearttree
The figure. Default person.
totaloptionalnumbergrid, pair: what the field stands for. Default 100.
unitoptionalnumbercompare, stacked: the value of one figure. Default 1.
unitLabeloptionalstringcompare, stacked: the key, e.g. "One figure is 1,000 visitors"
formatoptionalIntl.NumberFormatOptions
Built-in texts
Pictogram chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 8 texts
| Key | English | German |
|---|---|---|
| item | English Item | German Eintrag |
| value | English Value | German Wert |
| share | English Share | German Anteil |
| of | English {n} of {total} | German {n} von {total} |
| diff | English {d} points | German {d} Punkte |
| figures | English {n} figures | German {n} Figuren |
| rest | English Other | German Übrige |
| change | English Change | German Veränderung |
Accessibility
Read from the source of the component.
- Pictogram chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 399 lines
'use client';
import { useId, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, numberFormatter, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Counts drawn as rows of small figures (isotype).
* grid a field of 100 figures, the parts colored in order, the
* rest pale, each part with its share in the key
* compare one row per item, one figure per unit, the last figure cut
* to its fraction, the value at the end
* stacked one row per period, the parts follow each other in one row
* of figures, a figure that two parts share is split between them
* pair two fields of 100 side by side, before and after or one group
* against another, the difference between them
*
* The figures are drawn here (person, house, cup, leaf, drop, car, box,
* heart, tree), filled, on a 24 unit grid. A cut figure is the same figure
* seen through a narrower window, so a third of a person stays a person.
*/
export type PictogramVariant = 'grid' | 'compare' | 'stacked' | 'pair';
export type PictogramIcon = 'person' | 'house' | 'cup' | 'leaf' | 'drop' | 'car' | 'box' | 'heart' | 'tree';
export interface PictogramItem {
label: string;
/** grid, pair: the count out of `total`. compare: the value. stacked: unused, see parts. */
value?: number;
/** stacked: one value per part, in the order of `parts` */
values?: number[];
/** compare: a figure for this row only */
icon?: PictogramIcon;
}
export interface PictogramChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: PictogramVariant;
items: PictogramItem[];
/** stacked: names of the parts (up to four) */
parts?: string[];
/** The figure. Default person. */
icon?: PictogramIcon;
/** grid, pair: what the field stands for. Default 100. */
total?: number;
/** compare, stacked: the value of one figure. Default 1. */
unit?: number;
/** compare, stacked: the key, e.g. "One figure is 1,000 visitors" */
unitLabel?: string;
format?: Intl.NumberFormatOptions;
className?: string;
}
const PARTS = ['var(--kitch-1)', 'var(--kitch-2)', 'var(--kitfg-3)', 'var(--kitch-3)'];
const partColor = (k: number) => PARTS[Math.max(0, Math.min(3, k))];
const TEXTS = {
en: { item: 'Item', value: 'Value', share: 'Share', of: '{n} of {total}', diff: '{d} points', figures: '{n} figures', rest: 'Other', change: 'Change' },
de: { item: 'Eintrag', value: 'Wert', share: 'Anteil', of: '{n} von {total}', diff: '{d} Punkte', figures: '{n} Figuren', rest: 'Übrige', change: 'Veränderung' },
};
/** Figures on a 24 unit grid, filled, even-odd for the cut-outs */
const ICONS: Record<PictogramIcon, string> = {
person: 'M12 1.6a3.3 3.3 0 1 1 0 6.6a3.3 3.3 0 1 1 0-6.6Z M8.7 9.4h6.6a2.2 2.2 0 0 1 2.2 2.2V16h-2.3v6.4H8.8V16H6.5v-4.4a2.2 2.2 0 0 1 2.2-2.2Z',
house: 'M12 2.4L22.2 11H19.6v10.8H14.6V15.2h-5.2v6.6H4.4V11H1.8Z',
cup: 'M3.6 7.2h13.6v5.6a6.2 6.2 0 0 1-6.2 6.2H9.8a6.2 6.2 0 0 1-6.2-6.2Z M17.2 8.8h1.4a3.2 3.2 0 0 1 0 6.4h-1.9a7 7 0 0 0 .4-2h1.5a1.2 1.2 0 0 0 0-2.4h-1.4Z M2.8 20.4h15.6v1.9H2.8Z M8 1.8h1.8v3.6H8Z M11.4 2.6h1.8v2.8h-1.8Z',
leaf: 'M20.6 2.6C11.4 2.8 4.6 7.4 4.6 14.6c0 1.9.5 3.5 1.3 4.8L3.4 21.9l1.3 1.3 2.5-2.5c1.3.8 2.9 1.3 4.8 1.3 7.4 0 10.6-8.4 8.6-20.4Z M8.2 18.4c2.2-4.6 5.6-8 9.2-10.2c-2.8 2.8-5.4 6.2-7.6 11Z',
drop: 'M12 2.2c0 0-7.2 8.2-7.2 13.2a7.2 7.2 0 0 0 14.4 0c0-5-7.2-13.2-7.2-13.2Z',
car: 'M6.2 5.4h11.6l2.8 6.4v8.4h-2.8V22h-3v-1.8H9.2V22h-3v-1.8H3.4v-8.4Z M7.8 7.6L6 11.6h12l-1.8-4Z M5.8 14a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0Z M15.4 14a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0Z',
box: 'M12 1.8l9.4 4.6v11.2L12 22.2l-9.4-4.6V6.4Z M5.6 7.2L12 10.4l6.4-3.2L12 4.2Z',
heart: 'M12 21.2s-9-5.4-9-11.6A5 5 0 0 1 12 6.6a5 5 0 0 1 9 3C21 15.8 12 21.2 12 21.2Z',
tree: 'M12 1.8l6.2 8.2h-3l5.2 6.8h-6.8V22h-3.2v-5.2H3.6L8.8 10h-3Z',
};
/**
* Isotype charts: a field of 100, rows to compare, stacked rows and a pair of fields.
* <PictogramChart variant="grid" icon="person" items={[{ label: 'Walk', value: 46 }, { label: 'Bike', value: 31 }]} />
*/
export function PictogramChart(props: PictogramChartProps) {
const { variant = 'grid', items, parts = [], icon = 'person', total = 100, unit = 1, unitLabel, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const phone = width < 520;
const name = label ?? title ?? '';
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const P = parts.slice(0, 4);
const sym = (k: PictogramIcon) => `${uid}-${k}`;
const usedIcons = [...new Set([icon, ...items.map((it) => it.icon).filter((x): x is PictogramIcon => !!x)])];
/** One figure, or a window of it from f0 to f1 (0 to 1 of its width) */
const figure = (key: string, k: PictogramIcon, x: number, y: number, s: number, color: string, f0 = 0, f1 = 1, extra?: React.SVGProps<SVGSVGElement>) =>
f1 - f0 <= 0.001 ? null : (
<svg key={key} x={round(x + f0 * s)} y={round(y)} width={round((f1 - f0) * s)} height={round(s)} viewBox={`${round(f0 * 24, 3)} 0 ${round((f1 - f0) * 24, 3)} 24`} preserveAspectRatio="none" overflow="hidden" {...extra}>
<use href={`#${sym(k)}`} width={24} height={24} fill={color} />
</svg>
);
const defs = (
<defs>
{usedIcons.map((k) => (
<symbol key={k} id={sym(k)} viewBox="0 0 24 24">
<path d={ICONS[k]} fillRule="evenodd" />
</symbol>
))}
</defs>
);
const onKeyDown = (n: number) => (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const base = {
label: name,
labels,
onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0),
onBlur: () => setActive(null),
onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
};
// Small figures need four colors that part at a glance in both modes: accent, grey, deep accent, pale accent
const EMPTY = 'color-mix(in oklab, var(--foreground) 9%, transparent)';
/* ---------------- grid and pair: fields of 100 ---------------- */
if (variant === 'grid' || variant === 'pair') {
const fields = variant === 'pair' ? items.slice(0, 2) : [null];
const cells = 100;
const cols = 10;
const gapR = 0.22;
const fieldsN = fields.length;
const avail = variant === 'pair' ? (phone ? width : (width - 48) / 2) : phone ? width : Math.min(width * 0.58, 460);
const s = Math.min(phone ? 30 : 34, avail / (cols + (cols - 1) * gapR));
const step = s * (1 + gapR);
const fieldW = cols * step - s * gapR;
const fieldH = (cells / cols) * step - s * gapR;
// grid: the parts in order. pair: one part per field, the item's value.
const segs = variant === 'grid' ? items.slice(0, 4).map((it) => Math.max(0, ((it.value ?? 0) / (total || 1)) * cells)) : [];
const colorAt = (fi: number, c: number): { color: string; part: number; f0: number; f1: number }[] => {
// Portions of cell c (index along reading order) covered by each part
if (variant === 'pair') {
const v = Math.max(0, ((items[fi]?.value ?? 0) / (total || 1)) * cells);
const cov = Math.max(0, Math.min(1, v - c));
return [...(cov > 0 ? [{ color: fi === fieldsN - 1 ? partColor(0) : partColor(1), part: fi, f0: 0, f1: cov }] : []), ...(cov < 1 ? [{ color: EMPTY, part: -1, f0: cov, f1: 1 }] : [])];
}
const out: { color: string; part: number; f0: number; f1: number }[] = [];
let acc = 0;
segs.forEach((v, k) => {
const a = Math.max(c, acc);
const b = Math.min(c + 1, acc + v);
if (b > a) out.push({ color: partColor(k), part: k, f0: a - c, f1: b - c });
acc += v;
});
const covered = Math.min(c + 1, Math.max(c, acc)) - c;
if (covered < 1) out.push({ color: EMPTY, part: -1, f0: Math.max(0, covered), f1: 1 });
return out;
};
const n = variant === 'grid' ? Math.min(4, items.length) : fields.length;
const live = active !== null && items[active] ? `${items[active].label}, ${fill(t.of, { n: fmt(items[active].value ?? 0), total: fmt(total) })}, ${pct((items[active].value ?? 0) / (total || 1))}` : '';
const auto = items.map((it) => `${it.label} ${pct((it.value ?? 0) / (total || 1))}`).join(', ');
const table = <ChartTable caption={name} head={[t.item, t.value, t.share]} rows={items.map((it) => [it.label, fill(t.of, { n: fmt(it.value ?? 0), total: fmt(total) }), pct((it.value ?? 0) / (total || 1))])} />;
const svgW = variant === 'pair' ? (phone ? fieldW : width) : fieldW;
const fieldX = (fi: number) => (variant === 'pair' && !phone ? fi * (width / 2) + (width / 2 - fieldW) / 2 : 0);
const fieldY = (fi: number) => (variant === 'pair' && phone ? fi * (fieldH + 64) + 34 : variant === 'pair' ? 34 : 0);
const svgH = variant === 'pair' ? (phone ? fieldsN * (fieldH + 64) : fieldH + 34) : fieldH;
const diff = variant === 'pair' && items.length >= 2 ? (items[1].value ?? 0) - (items[0].value ?? 0) : 0;
const keyList = (
<ul className={cn('grid content-start gap-1.5', variant === 'grid' && !phone ? 'min-w-0 flex-1' : 'mt-5')}>
{(variant === 'grid' ? items.slice(0, 4) : items.slice(0, 2)).map((it, k) => {
const color = variant === 'grid' ? partColor(k) : k === fieldsN - 1 ? partColor(0) : partColor(1);
return (
<li key={it.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(k)} onPointerDown={() => setActive(k)} className={cn('flex items-baseline gap-3 rounded-md px-2 py-1.5 transition-[background-color,opacity] duration-200', active === k && 'bg-accent', active !== null && active !== k && 'opacity-55')}>
<span className="translate-y-[1px] self-center">
<Swatch color={color} />
</span>
<span className="min-w-0 flex-1 text-[13px] text-foreground">{it.label}</span>
<span className={cn('text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground', anim && 'kitch-rise')} style={delay(400 + k * 80)}>
{pct((it.value ?? 0) / (total || 1))}
</span>
</li>
);
})}
{variant === 'grid' && segs.reduce((a, b) => a + b, 0) < cells - 0.5 && (
<li className="flex items-baseline gap-3 px-2 py-1.5 text-muted-foreground">
<span className="self-center">
<Swatch color={EMPTY} />
</span>
<span className="min-w-0 flex-1 text-[13px]">{t.rest}</span>
<span className="text-[15px] font-medium tabular-nums">{pct(1 - segs.reduce((a, b) => a + b, 0) / cells)}</span>
</li>
)}
</ul>
);
const tip = shown !== null && items[shown];
const tipRows: TooltipRow[] = tip ? [{ key: 'v', label: t.value, value: fill(t.of, { n: fmt(items[shown!].value ?? 0), total: fmt(total) }) }, { key: 's', label: t.share, value: pct((items[shown!].value ?? 0) / (total || 1)) }] : [];
const pick = (fi: number, c: number) => {
if (variant === 'pair') return setActive(fi);
const part = colorAt(0, c).find((p) => p.part >= 0)?.part;
setActive(part ?? null);
};
// The figure under the pointer, from its position (gaps between figures count to the nearest)
const pickAt = (e: React.PointerEvent<HTMLDivElement>) => {
const b = e.currentTarget.getBoundingClientRect();
const px = e.clientX - b.left;
const py = e.clientY - b.top;
for (let fi = 0; fi < fieldsN; fi++) {
const x = px - fieldX(fi);
const y = py - fieldY(fi);
if (x < -4 || y < -4 || x > fieldW + 4 || y > fieldH + 4) continue;
const col = Math.max(0, Math.min(cols - 1, Math.floor(x / step)));
const row = Math.max(0, Math.min(cells / cols - 1, Math.floor(y / step)));
return pick(fi, row * cols + col);
}
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef} className={cn(variant === 'grid' && !phone && 'flex items-start gap-8')}>
<ChartSurface {...base} summary={summary ?? auto} live={live} table={table} onKeyDown={onKeyDown(n)} onPointerMove={pickAt} onPointerDown={pickAt} className={cn(variant === 'grid' && !phone && 'shrink-0')}>
<svg width={svgW} height={svgH} viewBox={`0 0 ${round(svgW)} ${round(svgH)}`} aria-hidden className="block max-w-full overflow-visible">
{defs}
{fields.map((_, fi) => (
<g key={fi}>
{variant === 'pair' && (
<g>
<text x={fieldX(fi)} y={fieldY(fi) - 22} className="text-[13px] font-medium" fill="var(--foreground)" dy="0.2em">
{items[fi]?.label}
</text>
<text x={fieldX(fi) + fieldW} y={fieldY(fi) - 22} textAnchor="end" className="text-[20px] font-semibold tabular-nums" fill="var(--foreground)" dy="0.3em">
{pct((items[fi]?.value ?? 0) / (total || 1))}
</text>
</g>
)}
<g opacity={variant === 'pair' && active !== null && active !== fi ? 0.4 : 1} className="transition-opacity duration-200">
{Array.from({ length: cells }, (_, c) => {
const x = fieldX(fi) + (c % cols) * step;
const y = fieldY(fi) + Math.floor(c / cols) * step;
const portions = colorAt(fi, c);
const dim = variant === 'grid' && active !== null && !portions.some((p) => p.part === active);
return (
<g key={c} opacity={dim ? 0.3 : 1} onPointerEnter={(e) => e.pointerType === 'mouse' && pick(fi, c)} onPointerDown={() => pick(fi, c)} className={cn('cursor-pointer transition-opacity duration-200', anim && 'kitch-pop')} style={delay(60 + fi * 200 + (Math.floor(c / cols) + (c % cols)) * 28)}>
{portions.map((p, k) => figure(`${c}-${k}`, icon, x, y, s, p.color, p.f0, p.f1))}
</g>
);
})}
</g>
</g>
))}
</svg>
{variant === 'grid' && <ChartTooltip open={open && phone} jump={jump} x={fieldW / 2} y={0} width={fieldW} title={tip ? items[shown!].label : ''} rows={tipRows} />}
</ChartSurface>
{variant === 'grid' && keyList}
</div>
{variant === 'pair' && items.length >= 2 && (
<p className={cn('mt-5 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[13px] text-muted-foreground', anim && 'kitch-rise')} style={delay(900)}>
<span>{t.change}</span>
<span className={cn('text-[15px] font-semibold tabular-nums', diff >= 0 ? 'text-success-foreground' : 'text-destructive')}>{`${diff > 0 ? '+' : diff < 0 ? '−' : '±'}${fill(t.diff, { d: new Intl.NumberFormat(intl, { maximumFractionDigits: 1 }).format(Math.abs((diff / (total || 1)) * 100)) })}`}</span>
<span>{`${items[0].label} → ${items[1].label}`}</span>
</p>
)}
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- compare and stacked: rows of figures ---------------- */
const rowVals = items.map((it) => (variant === 'stacked' ? P.map((_, k) => Math.max(0, it.values?.[k] ?? 0)) : [Math.max(0, it.value ?? 0)]));
const rowTotals = rowVals.map((v) => v.reduce((a, b) => a + b, 0));
const maxUnits = Math.max(1, ...rowTotals.map((v) => v / (unit || 1)));
const labelW = phone ? 0 : Math.min(200, Math.max(...items.map((it) => textWidth(it.label, 13)), 40) + 20);
const endW = Math.max(...rowTotals.map((v) => textWidth(fmt(v), 14)), 30) + 14;
const iconsW = Math.max(60, width - labelW - endW);
const gapR = 0.16;
const s = Math.max(10, Math.min(phone ? 22 : 28, iconsW / (Math.ceil(maxUnits) * (1 + gapR))));
const step = s * (1 + gapR);
const perRow = Math.max(1, Math.floor((iconsW + s * gapR) / step));
const lines = (units: number) => Math.max(1, Math.ceil(units / perRow));
const rowGap = phone ? 18 : 14;
const heads = phone ? 22 : 0;
const rowHs = rowTotals.map((v) => heads + lines(v / (unit || 1)) * step - s * gapR);
const rowYs: number[] = [];
rowHs.reduce((y, h) => (rowYs.push(y), y + h + rowGap), 0);
const svgH = rowYs[rowYs.length - 1] + rowHs[rowHs.length - 1];
const colorOf = (k: number) => (variant === 'stacked' ? partColor(k) : 'var(--kitch-1)');
const live = active !== null && items[active] ? `${items[active].label}, ${fmt(rowTotals[active])}${variant === 'stacked' ? `. ${P.map((p, k) => `${p} ${fmt(rowVals[active][k])}`).join(', ')}` : ''}` : '';
const auto = items.map((it, i) => `${it.label} ${fmt(rowTotals[i])}`).join(', ');
const table = <ChartTable caption={name} head={[t.item, ...(variant === 'stacked' ? P : [t.value]), ...(variant === 'stacked' ? [t.value] : [])]} rows={items.map((it, i) => [it.label, ...(variant === 'stacked' ? rowVals[i].map(fmt) : []), fmt(rowTotals[i])])} />;
const pickRow = (py: number) => {
const i = rowYs.findIndex((y, k) => py >= y - rowGap / 2 && py < y + rowHs[k] + rowGap / 2);
setActive(i >= 0 ? i : null);
};
const tipRows: TooltipRow[] = shown !== null && items[shown] ? (variant === 'stacked' ? [...P.map((p, k) => ({ key: p, label: p, value: fmt(rowVals[shown][k]), color: colorOf(k) })), { key: '__total', label: t.value, value: fmt(rowTotals[shown]) }] : [{ key: 'v', label: t.value, value: fmt(rowTotals[shown]) }, { key: 'f', label: unitLabel ?? '', value: fill(t.figures, { n: new Intl.NumberFormat(intl, { maximumFractionDigits: 1 }).format(rowTotals[shown] / (unit || 1)) }) }]) : [];
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{(variant === 'stacked' || unitLabel) && (
<div className="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-[12px] text-muted-foreground">
{variant === 'stacked' ? (
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
{P.map((p, k) => (
<li key={p} className="flex items-center gap-1.5">
<Swatch color={colorOf(k)} />
{p}
</li>
))}
</ul>
) : (
<span />
)}
{unitLabel && (
<span className="inline-flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path d={ICONS[icon]} fillRule="evenodd" fill="var(--muted-foreground)" />
</svg>
{unitLabel}
</span>
)}
</div>
)}
<div ref={wrapRef}>
<ChartSurface
{...base}
summary={summary ?? auto}
live={live}
table={table}
onKeyDown={onKeyDown(items.length)}
onPointerMove={(e) => pickRow(e.clientY - e.currentTarget.getBoundingClientRect().top)}
onPointerDown={(e) => pickRow(e.clientY - e.currentTarget.getBoundingClientRect().top)}
>
<svg width="100%" height={round(svgH)} viewBox={`0 0 ${width} ${round(svgH)}`} aria-hidden className="block overflow-visible">
{defs}
{items.map((it, i) => {
const y0 = rowYs[i];
const units = rowTotals[i] / (unit || 1);
const nIcons = Math.ceil(units - 1e-9);
const k0 = it.icon ?? icon;
const bounds: number[] = [];
rowVals[i].reduce((a, v) => (bounds.push(a + v / (unit || 1)), a + v / (unit || 1)), 0);
const on = active === i;
return (
<g key={i} opacity={active !== null && !on ? 0.4 : 1} className="transition-opacity duration-200">
{active === i && <rect x={-6} y={y0 - rowGap / 2 + 2} width={width + 12} height={rowHs[i] + rowGap - 4} rx={8} fill="var(--foreground)" opacity={0.045} />}
<text x={0} y={phone ? y0 + 8 : y0 + (Math.min(rowHs[i], s) / 2)} dy="0.34em" className={cn('text-[13px]', on ? 'font-semibold' : 'font-medium')} fill="var(--foreground)">
{it.label}
</text>
<text x={width} y={phone ? y0 + 8 : y0 + (Math.min(rowHs[i], s) / 2)} dy="0.34em" textAnchor="end" className={cn('text-[14px] font-semibold tabular-nums', anim && 'kitch-fade')} fill="var(--foreground)" style={delay(500 + i * 90)}>
{fmt(rowTotals[i])}
</text>
{Array.from({ length: nIcons }, (_, c) => {
const x = labelW + (c % perRow) * step;
const y = y0 + heads + Math.floor(c / perRow) * step;
const portions: React.ReactNode[] = [];
let prev = 0;
bounds.forEach((b, k) => {
const a = Math.max(c, prev);
const e = Math.min(c + 1, b);
if (e > a) portions.push(figure(`${c}-${k}`, k0, x, y, s, colorOf(k), a - c, e - c));
prev = b;
});
const filled = Math.min(1, Math.max(0, units - c));
if (filled < 1) portions.push(figure(`${c}-e`, k0, x, y, s, EMPTY, filled, 1));
return (
<g key={c} className={anim ? 'kitch-pop' : undefined} style={delay(80 + i * 90 + c * 22)}>
{portions}
</g>
);
})}
</g>
);
})}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null && items[shown] ? Math.min(width - endW, labelW + Math.min(perRow, Math.ceil(rowTotals[shown] / (unit || 1))) * step) : 0} y={shown !== null && rowYs[shown] !== undefined ? Math.max(0, rowYs[shown] - 6) : 0} width={width} offset={10} title={shown !== null && items[shown] ? items[shown].label : ''} rows={tipRows} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Pictogram chart?
With aiw add @ai-website/pictogram-chart. The command puts components/ui/pictogram-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.
Which variants does Pictogram chart have?
4 variants, Field of 100, Rows to compare, Stacked rows and Two fields. You pick one with the variant prop, for example variant="compare".
Can I translate the texts of Pictogram chart?
Yes. Pictogram chart carries 8 texts of its own in English and German, such as “Item”, “Value” and “Share”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Pictogram chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Pictogram chart with aiw_components and install it with aiw_add @ai-website/pictogram-chart. Name the variant in your prompt, for example variant="grid", and the assistant writes your content straight into the props.