Proportion chart
Shares that add up to 100%, as stacked rows, columns that show the mix over time, a marimekko where width is weight, or survey answers around a neutral middle.
One of 5 components in Comparison charts
Details
What Proportion chart brings along, read from the registry and the source.
- Category
- Comparison charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/proportion-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProportionChartProps interface from components/ui/proportion-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.
variantoptionalProportionVariantstackedcolumnsmarimekkolikert
partsrequiredstring[]Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first)
rowsrequiredProportionRow[]formatoptionalIntl.NumberFormatOptionsValue format of the absolute values in tooltips and table
Built-in texts
Proportion chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| item | English Row | German Zeile |
| total | English Total | German Gesamt |
| net | English Net agreement | German Zustimmung netto |
| size | English Width is the size of each segment | German Die Breite ist die Größe jedes Segments |
Accessibility
Read from the source of the component.
- Proportion chart handles the keys Escape, Arrow up, Arrow down, Arrow left and Arrow right 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, 256 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, Swatch, numberFormatter, seriesColor, textWidth, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Shares that add up to 100 %.
* stacked one 100 % bar per row, the same parts in the same order
* columns one 100 % column per period, how the mix moves over time
* marimekko column width is the size of a segment, the parts inside it
* their share, so both the mix and the weight are honest
* likert survey answers from disagree to agree around a neutral
* middle, agreement to the right, the net share beside
*
* Up to four parts in the slot colors (they touch only their neighbours,
* the adjacent pair check holds), parted by a 2 px surface gap. A share
* label sits inside a part only where it fits.
*/
export type ProportionVariant = 'stacked' | 'columns' | 'marimekko' | 'likert';
export interface ProportionRow {
label: string;
/** One value per part (or per answer for likert, disagree first) */
values: number[];
}
export interface ProportionChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: ProportionVariant;
/** Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first) */
parts: string[];
rows: ProportionRow[];
/** Value format of the absolute values in tooltips and table */
format?: Intl.NumberFormatOptions;
className?: string;
}
const TEXTS = {
en: { item: 'Row', total: 'Total', net: 'Net agreement', size: 'Width is the size of each segment' },
de: { item: 'Zeile', total: 'Gesamt', net: 'Zustimmung netto', size: 'Die Breite ist die Größe jedes Segments' },
};
const LIKERT = ['color-mix(in oklab, var(--kitfg-down) 80%, var(--card))', 'color-mix(in oklab, var(--kitfg-down) 38%, var(--card))', 'color-mix(in oklab, var(--muted-foreground) 30%, var(--card))', 'color-mix(in oklab, var(--primary) 45%, var(--card))', 'var(--primary)'];
// Text on a part: dark ink on light tints, the surface color on the two full tones
const INK_LIKERT = ['var(--pc-dark)', 'var(--foreground)', 'var(--foreground)', 'var(--foreground)', 'var(--primary-foreground)'];
/**
* 100 % shares: stacked rows, columns over time, a marimekko or survey answers.
* <ProportionChart variant="likert" parts={['Disagree strongly', …, 'Agree strongly']} rows={[{ label: 'Easy to book', values: [4, 9, 18, 41, 28] }]} />
*/
export function ProportionChart(props: ProportionChartProps) {
const { variant = 'stacked', parts, rows, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const signedPct = (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<number | null>(null);
const [focus, setFocus] = useState<number | null>(null);
const name = label ?? title ?? '';
const phone = width < 520;
const P = variant === 'likert' ? parts.slice(0, 5) : parts.slice(0, 4);
const colorOf = (k: number) => (variant === 'likert' ? LIKERT[k] : seriesColor(k));
const inkOf = (k: number) => (variant === 'likert' ? INK_LIKERT[k] : k === 0 ? 'var(--primary-foreground)' : k === 1 ? 'var(--background)' : 'var(--foreground)');
const sums = rows.map((r) => r.values.slice(0, P.length).reduce((a, v) => a + Math.max(0, v), 0) || 1);
const shares = rows.map((r, i) => r.values.slice(0, P.length).map((v) => Math.max(0, v) / sums[i]));
const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const xf = xFormatters(intl, rows[0]?.label);
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(rows.length - 1, cur + 1);
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const rowName = (r: ProportionRow) => (variant === 'columns' ? xf.tooltip(r.label) : r.label);
const live = active !== null && rows[active] ? `${rowName(rows[active])}. ${P.map((p, k) => `${p} ${pct(shares[active][k])}`).join(', ')}` : '';
const table = <ChartTable caption={name} head={[t.item, ...P, t.total]} rows={rows.map((r, i) => [rowName(r), ...P.map((_, k) => `${pct(shares[i][k])} (${fmt(r.values[k] ?? 0)})`), fmt(sums[i])])} />;
const legend = <PickLegend className="mb-4" active={focus} onActive={setFocus} items={P.map((p, k) => ({ label: p, key: <Swatch color={colorOf(k)} /> }))} />;
const dim = (k: number) => (focus !== null && focus !== k ? 0.22 : 1);
const surfaceProps = { label: name, summary: summary ?? '', labels, live, table, onKeyDown, 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) };
/* ---------------- stacked rows and likert ---------------- */
if (variant === 'stacked' || variant === 'likert') {
const labelW = phone ? 0 : Math.min(220, Math.max(...rows.map((r) => textWidth(r.label, 13))) + 20);
const netW = variant === 'likert' && !phone ? 64 : 0;
// Likert: neutral centred on the middle line, disagreement to the left
const left = (i: number) => (variant === 'likert' ? 0.5 - shares[i][0] - shares[i][1] - shares[i][2] / 2 : 0);
const minLeft = variant === 'likert' ? Math.min(...rows.map((_, i) => left(i))) : 0;
const maxRight = variant === 'likert' ? Math.max(...rows.map((_, i) => left(i) + 1)) : 1;
const span = maxRight - minLeft;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
{legend}
<div ref={wrapRef}>
<ChartSurface {...surfaceProps}>
<ul className="relative grid gap-3">
{variant === 'likert' && (
<span aria-hidden className="pointer-events-none absolute inset-y-0 w-px bg-[var(--kitch-axis)]" style={{ left: `calc(${labelW}px + (100% - ${labelW + netW}px) * ${round((0.5 - minLeft) / span, 4)})` }} />
)}
{rows.map((r, i) => {
const net = variant === 'likert' ? shares[i][3] + shares[i][4] - shares[i][0] - shares[i][1] : 0;
let acc = (left(i) - minLeft) / span;
return (
<li key={r.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className={cn('grid items-center gap-x-3 gap-y-1.5 rounded-md transition-opacity duration-200', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : '', active !== null && active !== i && 'opacity-60')} style={phone ? undefined : { gridTemplateColumns: `${labelW - 12}px minmax(0,1fr)${netW ? ` ${netW - 12}px` : ''}` }}>
<span className={cn('truncate text-[13px] text-foreground', active === i && 'font-medium')}>{r.label}</span>
{phone && variant === 'likert' && <span className="text-right text-[12px] font-medium tabular-nums text-foreground">{signedPct(net)}</span>}
<span className={cn('relative h-7 min-w-0', phone && 'col-span-2')}>
{shares[i].map((s, k) => {
const x = acc;
acc += s / span;
const w = s / span;
const text = pct(s);
const fits = s > 0 && textWidth(text, 11) + 10 <= w * (width - labelW - netW);
return (
<span
key={k}
className={cn('kitch-series absolute inset-y-0 flex items-center justify-center overflow-hidden text-[11px] font-medium tabular-nums', anim && 'kitch-grow-x', k === 0 && 'rounded-l-[5px]', k === P.length - 1 && 'rounded-r-[5px]')}
style={{ left: `calc(${round(x * 100, 3)}% + ${k ? 1 : 0}px)`, width: `calc(${round(w * 100, 3)}% - ${k && k < P.length - 1 ? 2 : 1}px)`, background: colorOf(k), color: inkOf(k), opacity: dim(k), ...d(120 + i * 70 + k * 40) }}
>
{fits ? text : ''}
</span>
);
})}
</span>
{!phone && variant === 'likert' && <span className="text-right text-[13px] font-semibold tabular-nums text-foreground">{signedPct(net)}</span>}
</li>
);
})}
</ul>
{variant === 'likert' && <p className="mt-3 text-right text-[12px] text-muted-foreground">{t.net}</p>}
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- columns over time and marimekko ---------------- */
const height = phone ? 260 : 300;
const top = 6;
const bottom = height - 40;
const plotH = bottom - top;
const left = 36;
const right = width - 4;
const totalAll = sums.reduce((a, v) => a + v, 0) || 1;
const gap = variant === 'marimekko' ? 3 : 0;
const colW = variant === 'columns' ? Math.min(44, ((right - left) / rows.length) * 0.62) : 0;
const band = (right - left) / Math.max(1, rows.length);
let xAcc = left;
const cols = rows.map((r, i) => {
if (variant === 'marimekko') {
const w = (sums[i] / totalAll) * (right - left - gap * (rows.length - 1));
const c = { x: xAcc, w };
xAcc += w + gap;
return c;
}
return { x: left + band * i + (band - colW) / 2, w: colW };
});
const pick = (clientX: number, el: HTMLElement) => {
const b = el.getBoundingClientRect();
const px = (clientX - b.left) * (width / (b.width || width));
const i = cols.findIndex((c) => px >= c.x - (variant === 'columns' ? (band - colW) / 2 : gap / 2) && px <= c.x + c.w + (variant === 'columns' ? (band - colW) / 2 : gap / 2));
setActive(i >= 0 ? i : null);
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
{legend}
<div ref={wrapRef}>
<ChartSurface {...surfaceProps} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
{[0, 0.25, 0.5, 0.75, 1].map((q) => (
<g key={q}>
<line x1={left} x2={right} y1={round(bottom - q * plotH)} y2={round(bottom - q * plotH)} stroke={q === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
<text x={left - 8} y={round(bottom - q * plotH)} dy="0.34em" textAnchor="end" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(q)}</text>
</g>
))}
{rows.map((r, i) => {
const c = cols[i];
let yAcc = bottom;
const on = active === i;
const lab = variant === 'columns' ? xf.axis(r.label) : r.label;
const labFits = textWidth(lab, 11) <= (variant === 'columns' ? band : c.w) - 4;
return (
<g key={r.label} opacity={active !== null && !on ? 0.55 : 1} className="transition-opacity duration-200">
{shares[i].map((s, k) => {
const h = s * plotH;
const y = yAcc - h;
yAcc = y;
const inner = Math.max(0, h - (k < P.length - 1 ? 2 : 0));
const text = pct(s);
const fits = textWidth(text, 11) + 8 <= c.w && inner >= 16;
return (
<g key={k} className="kitch-series" opacity={dim(k)}>
<rect x={round(c.x)} y={round(y + (k < P.length - 1 ? 2 : 0))} width={round(c.w)} height={round(inner)} rx={k === P.length - 1 ? 3 : 0} fill={colorOf(k)} className={anim ? 'kitch-grow-y' : undefined} style={{ ...d(100 + i * 70 + k * 50), transformOrigin: `${round(c.x + c.w / 2)}px ${bottom}px`, transformBox: 'view-box' }} />
{fits && (
<text x={round(c.x + c.w / 2)} y={round(y + h / 2 + 1)} dy="0.34em" textAnchor="middle" className={cn('text-[11px] font-medium tabular-nums', anim && 'kitch-fade')} fill={inkOf(k)} style={d(600 + i * 70)}>
{text}
</text>
)}
</g>
);
})}
{labFits && (
<text x={round(c.x + c.w / 2)} y={bottom + 18} textAnchor="middle" className={cn('text-[11px]', on && 'fill-foreground font-medium')} fill="var(--muted-foreground)">{lab}</text>
)}
{variant === 'marimekko' && labFits && (
<text x={round(c.x + c.w / 2)} y={bottom + 33} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(sums[i] / totalAll)}</text>
)}
</g>
);
})}
</svg>
</ChartSurface>
{variant === 'marimekko' && (
<p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
<span>{t.size}</span>
{/* Segments too narrow for their name under the column are named here, nothing goes missing */}
{rows.map((r, i) =>
textWidth(r.label, 11) > cols[i].w - 4 ? (
<span key={r.label} className="tabular-nums">
<span className="text-foreground">{r.label}</span> {pct(sums[i] / totalAll)}
</span>
) : null,
)}
</p>
)}
{active !== null && rows[active] && (
<p aria-hidden className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]">
<span className="font-medium text-foreground">{rowName(rows[active])}</span>
{P.map((p, k) => (
<span key={p} className="inline-flex items-center gap-1.5 text-muted-foreground">
<Swatch color={colorOf(k)} />
{p}
<span className="font-medium tabular-nums text-foreground">{pct(shares[active][k])}</span>
</span>
))}
</p>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Proportion chart?
With aiw add @ai-website/proportion-chart. The command puts components/ui/proportion-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 Proportion chart have?
4 variants, Stacked rows, Mix over time, Marimekko and Survey answers. You pick one with the variant prop, for example variant="columns".
Can I translate the texts of Proportion chart?
Yes. Proportion chart carries 4 texts of its own in English and German, such as “Row”, “Total” and “Net agreement”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Proportion chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Proportion chart with aiw_components and install it with aiw_add @ai-website/proportion-chart. Name the variant in your prompt, for example variant="stacked", and the assistant writes your content straight into the props.
Can I adjust Proportion chart without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ProportionChart code to copy.