Stacked bar chart
Parts of a total with every sum written out. Stacked columns with totals, 100 % rows with a total row, sorted rows with a sum line and two periods in one row. A series picked in the legend moves onto the baseline.
Parts of a total, with every sum written out
A stacked bar chart shows how a total splits into parts, channels per month or teams by share. This one writes every sum out and lets the legend set any series on the baseline, which the plain stacked mode of the bar chart cannot, so each part becomes comparable on its own.
Columns with totals stacks columns per month with the value in each part that fits and the total on top, pick a channel to set it on the baseline. 100 % rows shows each team as a full row of shares with the ticket count at the end and a total row below that stacks every team into one. Sorted rows with sum line sorts absolute rows by total with the total at the end of each row and a sum line that adds up every series. Two periods draws this year as the full bar and last year thin and pale above it on the same scale, with the change of the total at the end.
Keep stacks to four or five series, beyond that the middle parts are hard to compare. Put the most important series on the baseline, it is the only one that reads exactly. Sort rows by total unless the order itself means something, like months.
Variants
Details
What Stacked bar 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/stacked-bar-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 9 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The StackedBarChartProps interface from components/ui/stacked-bar-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.
variantoptionalStackedBarVariantcolumnspercenthorizontalcompare
datarequiredDatum[]Rows, one per category, e.g. { month: '2026-03', online: 21400, store: 9800 }
xKeyrequiredstringKey of the category. "2026-03" is read as a month and formatted per language.
seriesrequiredChartSeries[]The parts, in slot order (accent, grey, two accent tones). At most four.
previousoptionalDatum[]compare: the earlier period, the same categories and keys as data
periodsoptional[string, string]compare: names of the two periods, earlier first, e.g. ['2025', '2026']
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
xFormatoptionalIntl.DateTimeFormatOptionstooltipXFormatoptionalIntl.DateTimeFormatOptionssegmentLabelsoptionalbooleanValues or shares inside the parts, only where they fit. Default true.
totalRowoptionalbooleanpercent: a total row that stacks all rows. horizontal: the sum line per series. Default true.
sortoptionalbooleanhorizontal and compare: sort rows by total, largest first. Default true for horizontal.
defaultFocusoptionalnumber | nullThe series set on the baseline at the start (index), none by default
heightoptionalnumbercolumns: plot height in px, axis labels included. Default 300, 260 on phones.
xLabeloptionalstringHeader of the first table column
Built-in texts
Stacked bar chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 9 texts
| Key | English | German |
|---|---|---|
| total | English Total | German Gesamt |
| all | English All | German Alle |
| share | English Share | German Anteil |
| change | English Change | German Veränderung |
| category | English Category | German Kategorie |
| base | English Set a series on the baseline | German Eine Reihe an die Grundlinie setzen |
| baseOn | English {series} on the baseline | German {series} an der Grundlinie |
| sum | English Sum | German Summe |
| of | English of the total | German vom Gesamtwert |
Accessibility
Read from the source of the component.
- Stacked bar chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
- 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, 453 lines
'use client';
import { useId, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, barPath, fill, niceTicks, numberFormatter, seriesColor, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, xFormatters, type ChartA11y, type ChartSeries, type Datum, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Parts of a total, stacked, with the sums written out.
* columns stacked columns, a value inside each part that fits and the total on top
* percent 100 % rows, the share inside each part, the absolute total at the end,
* and a total row that stacks every row into one
* horizontal absolute rows sorted by total, the total at the end and a sum line
* below that adds up every series
* compare two periods per row, the earlier one thin and pale under the current
* one, the change of the total at the end
*
* The legend is a set of toggles: picking a series sets it on the baseline,
* so its values line up and compare across rows, the others step back.
* Up to four series in the slot colors, parted by a 2 px surface gap.
* The plain stacked bar of bar-chart has none of this.
*/
export type StackedBarVariant = 'columns' | 'percent' | 'horizontal' | 'compare';
export interface StackedBarChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: StackedBarVariant;
/** Rows, one per category, e.g. { month: '2026-03', online: 21400, store: 9800 } */
data: Datum[];
/** Key of the category. "2026-03" is read as a month and formatted per language. */
xKey: string;
/** The parts, in slot order (accent, grey, two accent tones). At most four. */
series: ChartSeries[];
/** compare: the earlier period, the same categories and keys as data */
previous?: Datum[];
/** compare: names of the two periods, earlier first, e.g. ['2025', '2026'] */
periods?: [string, string];
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
xFormat?: Intl.DateTimeFormatOptions;
tooltipXFormat?: Intl.DateTimeFormatOptions;
/** Values or shares inside the parts, only where they fit. Default true. */
segmentLabels?: boolean;
/** percent: a total row that stacks all rows. horizontal: the sum line per series. Default true. */
totalRow?: boolean;
/** horizontal and compare: sort rows by total, largest first. Default true for horizontal. */
sort?: boolean;
/** The series set on the baseline at the start (index), none by default */
defaultFocus?: number | null;
/** columns: plot height in px, axis labels included. Default 300, 260 on phones. */
height?: number;
/** Header of the first table column */
xLabel?: string;
className?: string;
}
const TEXTS = {
en: { total: 'Total', all: 'All', share: 'Share', change: 'Change', category: 'Category', base: 'Set a series on the baseline', baseOn: '{series} on the baseline', sum: 'Sum', of: 'of the total' },
de: { total: 'Gesamt', all: 'Alle', share: 'Anteil', change: 'Veränderung', category: 'Kategorie', base: 'Eine Reihe an die Grundlinie setzen', baseOn: '{series} an der Grundlinie', sum: 'Summe', of: 'vom Gesamtwert' },
};
const GAP = 2;
const MAX = 56;
/** Text on a part: light on the accent and the grey, dark on the pale tones */
const ink = (k: number) => (k === 0 ? 'var(--primary-foreground)' : k === 1 ? 'var(--background)' : 'var(--foreground)');
/**
* Stacked bars with totals, shares, a sum line and two periods side by side.
* <StackedBarChart variant="percent" data={rows} xKey="team" series={[{ key: 'won', label: 'Won' }, { key: 'open', label: 'Open' }]} />
*/
export function StackedBarChart(props: StackedBarChartProps) {
const { variant = 'columns', data, xKey, series: allSeries, previous = [], periods, format, xFormat, tooltipXFormat, segmentLabels = true, totalRow = true, defaultFocus = null, height: heightProp, xLabel, 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 tick = tickFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const pct1 = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
const signed = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '±') + pct1(Math.abs(n));
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 [pinned, setPinned] = useState<number | null>(defaultFocus);
const [hover, setHover] = useState<number | null>(null);
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const series = allSeries.slice(0, 4);
const phone = width < 520;
const name = label ?? title ?? '';
const xf = xFormatters(intl, data[0]?.[xKey], xFormat, tooltipXFormat);
const num = (d: Datum | undefined, k: string) => (d && typeof d[k] === 'number' ? Math.max(0, d[k] as number) : 0);
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
// Rows in display order
const prevOf = (d: Datum) => previous.find((p) => String(p[xKey]) === String(d[xKey]));
const totalOf = (d: Datum | undefined) => series.reduce((a, s) => a + num(d, s.key), 0);
const sort = props.sort ?? variant === 'horizontal';
const rows = sort && variant !== 'columns' ? [...data].sort((a, b) => totalOf(b) - totalOf(a)) : data;
const n = rows.length;
const totals = rows.map((d) => totalOf(d));
const grand = totals.reduce((a, b) => a + b, 0);
const seriesSums = series.map((s) => rows.reduce((a, d) => a + num(d, s.key), 0));
const withTotalRow = variant === 'percent' && totalRow;
const count = n + (withTotalRow ? 1 : 0);
// Stack order: the picked series first, on the baseline
const order = pinned !== null && pinned < series.length ? [pinned, ...series.map((_, i) => i).filter((i) => i !== pinned)] : series.map((_, i) => i);
const focus = hover ?? pinned;
const dim = (k: number) => (focus !== null && focus !== k ? 0.28 : 1);
const catName = (i: number) => (i >= n ? t.all : xf.tooltip(rows[i][xKey]));
const catAxis = (i: number) => (i >= n ? t.all : xf.axis(rows[i][xKey]));
const valuesAt = (i: number) => (i >= n ? seriesSums : series.map((s) => num(rows[i], s.key)));
const totalAt = (i: number) => (i >= n ? grand : totals[i]);
const tipRows = (i: number): TooltipRow[] => {
const v = valuesAt(i);
const tot = totalAt(i) || 1;
const out: TooltipRow[] = [...order].reverse().map((k) => ({ key: series[k].key, label: series[k].label, value: variant === 'percent' ? `${pct(v[k] / tot)} · ${fmt(v[k])}` : fmt(v[k]), color: seriesColor(k, series[k].color) }));
out.push({ key: '__total', label: ct.total, value: fmt(totalAt(i)) });
if (variant === 'compare' && i < n) {
const p = prevOf(rows[i]);
const pt = totalOf(p);
if (p && pt) out.push({ key: 'prev', label: periods?.[0] ?? '', value: `${fmt(pt)} · ${signed(totalAt(i) / pt - 1)}` });
}
return out;
};
const live = active !== null && active < count ? `${catName(active)}. ${tipRows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
const auto = `${n} ${t.category}, ${ct.total} ${fmt(grand)}. ${series.map((s, k) => `${s.label} ${pct(seriesSums[k] / (grand || 1))}`).join(', ')}.`;
const table = (
<ChartTable
caption={name}
head={[xLabel ?? t.category, ...series.map((s) => s.label), ct.total, ...(variant === 'compare' ? [periods?.[0] ?? '', t.change] : [])]}
rows={[
...rows.map((d, i) => {
const p = prevOf(d);
const pt = totalOf(p);
return [catName(i), ...series.map((s) => (variant === 'percent' ? `${fmt(num(d, s.key))} (${pct(num(d, s.key) / (totals[i] || 1))})` : fmt(num(d, s.key)))), fmt(totals[i]), ...(variant === 'compare' ? [p ? fmt(pt) : ct.noData, p && pt ? signed(totals[i] / pt - 1) : ct.noData] : [])];
}),
[t.sum, ...seriesSums.map(fmt), fmt(grand), ...(variant === 'compare' ? [fmt(previous.reduce((a, d) => a + totalOf(d), 0)), ''] : [])],
]}
/>
);
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(count - 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 = count - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
/* Legend: toggles that set a series on the baseline */
const legend = (
<div className="mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<ul className="flex flex-wrap items-center gap-x-1 gap-y-1 text-[12px] text-muted-foreground" aria-label={t.base}>
{series.map((s, k) => (
<li key={s.key}>
<button
type="button"
aria-pressed={pinned === k}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(k)}
onPointerLeave={() => setHover(null)}
onClick={() => setPinned(pinned === k ? null : k)}
className={cn(
'relative inline-flex min-h-8 cursor-pointer items-center gap-1.5 rounded-md border px-2.5 outline-offset-2 transition-[background-color,border-color,color,opacity] duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring max-sm:min-h-11',
pinned === k ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-transparent hover:text-foreground',
focus !== null && focus !== k && 'opacity-60',
)}
>
<Swatch color={seriesColor(k, s.color)} />
{s.label}
</button>
</li>
))}
</ul>
{pinned !== null && series[pinned] && <span className="text-[12px] text-muted-foreground" aria-live="polite">{fill(t.baseOn, { series: series[pinned].label })}</span>}
</div>
);
const surface = {
label: name,
summary: summary ?? auto,
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),
};
/* ---------------- columns ---------------- */
if (variant === 'columns') {
const height = heightProp ?? (phone ? 260 : 300);
const nice = niceTicks(0, Math.max(1, ...totals), 5);
const tickLabels = nice.ticks.map(tick);
const left = Math.max(...tickLabels.map((s) => textWidth(s, 11)), 12) + 12;
const right = width - 4;
const band = (right - left) / Math.max(1, n);
// Totals that fit neither written out nor short across a narrow column stand upright above it (phones, tablets)
const maxTotal = Math.max(...totals, 0);
const flatTotals = textWidth(fmt(maxTotal), 11) <= band - 2 || textWidth(tick(maxTotal), 11) <= band - 2;
const uprightTotals = !flatTotals && band >= 13;
const top = uprightTotals ? Math.max(...totals.map((v) => textWidth(tick(v), 11)), 0) + 14 : 26;
const bottom = height - 26;
const thick = Math.min(MAX, band * 0.62);
const Y = (v: number) => round(bottom - (v / (nice.max || 1)) * (bottom - top));
const catLabels = rows.map((_, i) => catAxis(i));
const widest = Math.max(...catLabels.map((s) => textWidth(s, 11)), 1) + 8;
const every = Math.max(1, Math.ceil(widest / Math.max(1, band)));
const showTotals = flatTotals || uprightTotals;
const totalText = (v: number) => (!uprightTotals && textWidth(fmt(v), 11) <= band - 2 ? fmt(v) : tick(v));
const pick = (clientX: number, el: HTMLElement) => {
const b = el.getBoundingClientRect();
const px = (clientX - b.left) * (width / (b.width || width));
const i = Math.floor((px - left) / band);
setActive(i >= 0 && i < n ? i : null);
};
const tipX = shown !== null && shown < n ? round(left + band * (shown + 0.5)) : 0;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
<div ref={wrapRef}>
<ChartSurface {...surface} 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">
<defs>
{rows.map((_, i) => {
const x = round(left + band * (i + 0.5) - thick / 2);
return (
<clipPath key={i} id={`${uid}c${i}`}>
<path d={barPath(x, Y(totals[i]), thick, Math.max(0, bottom - Y(totals[i])), 4, 'up')} />
</clipPath>
);
})}
</defs>
<g className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
{nice.ticks.map((v, i) => (
<g key={v}>
<line x1={left} x2={right} y1={Y(v)} y2={Y(v)} stroke={v === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
<text x={left - 10} y={Y(v)} dy="0.34em" textAnchor="end">
{tickLabels[i]}
</text>
</g>
))}
{catLabels.map((s, i) =>
i % every === 0 ? (
<text key={i} x={round(left + band * (i + 0.5))} y={height - 7} textAnchor="middle" className={cn(active === i && 'fill-foreground')}>
{s}
</text>
) : null,
)}
</g>
{shown !== null && shown < n && <rect x={0} y={top - 22} width={round(Math.max(0, band - 4))} height={round(bottom - top + 22)} rx="6" fill="var(--foreground)" className="kitch-glide" data-jump={jump ? '' : undefined} style={{ transform: `translateX(${round(left + band * shown + 2)}px)`, opacity: open ? 0.045 : 0 }} />}
{rows.map((d, i) => {
const x = round(left + band * (i + 0.5) - thick / 2);
let acc = 0;
const visible = order.filter((k) => num(d, series[k].key) > 0);
return (
<g key={i} className="transition-opacity duration-200" opacity={active !== null && active !== i ? 0.6 : 1}>
<g clipPath={`url(#${uid}c${i})`} className={anim ? 'kitch-grow-y' : undefined} style={delay(80 + (i / Math.max(1, n)) * 420)}>
{visible.map((k, j) => {
const v = num(d, series[k].key);
const y0 = Y(acc);
acc += v;
const y1 = Y(acc);
const isTop = j === visible.length - 1;
const h = Math.max(0, y0 - y1 - (isTop ? 0 : GAP));
return <rect key={series[k].key} x={x} y={round(y1 + (isTop ? 0 : GAP))} width={round(thick)} height={round(h)} fill={seriesColor(k, series[k].color)} opacity={dim(k)} className="transition-[y,height,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" />;
})}
</g>
{segmentLabels &&
(() => {
let a2 = 0;
return visible.map((k) => {
const v = num(d, series[k].key);
const y0 = Y(a2);
a2 += v;
const y1 = Y(a2);
const text = tick(v);
if (y0 - y1 < 18 || textWidth(text, 11) + 4 > thick) return null;
return (
<text key={series[k].key} x={round(x + thick / 2)} y={round((y0 + y1) / 2)} dy="0.34em" textAnchor="middle" className={cn('text-[11px] font-medium tabular-nums transition-[y,opacity] duration-500 motion-reduce:transition-none', anim && 'kitch-fade')} fill={ink(k)} opacity={dim(k)} style={delay(620 + i * 40)}>
{text}
</text>
);
});
})()}
{showTotals && uprightTotals && (
// The rotation sits on a group, the entrance animation moves the text inside it
<g transform={`translate(${round(x + thick / 2)} ${Y(totals[i]) - 6}) rotate(-90)`}>
<text x={0} y={0} dy="0.34em" textAnchor="start" className={cn('text-[11px] font-semibold tabular-nums', anim && 'kitch-fade')} fill="var(--foreground)" style={delay(520 + (i / Math.max(1, n)) * 420)}>
{totalText(totals[i])}
</text>
</g>
)}
{showTotals && !uprightTotals && (
<text x={round(x + thick / 2)} y={Y(totals[i]) - 8} textAnchor="middle" className={cn('text-[11px] font-semibold tabular-nums', anim && 'kitch-rise')} fill="var(--foreground)" style={delay(520 + (i / Math.max(1, n)) * 420)}>
{totalText(totals[i])}
</text>
)}
</g>
);
})}
</svg>
<ChartTooltip open={open} jump={jump} x={tipX} y={top} width={width} offset={thick / 2 + 10} title={shown !== null && shown < n ? catName(shown) : ''} rows={shown !== null && shown < n ? tipRows(shown) : []} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- rows: percent, horizontal, compare ---------------- */
const labelW = phone ? 0 : Math.min(200, Math.max(...rows.map((_, i) => textWidth(catAxis(i), 13)), withTotalRow ? textWidth(t.all, 13) : 0) + 20);
const endTexts = rows.map((d, i) => {
if (variant === 'compare') {
const pt = totalOf(prevOf(d));
return pt ? signed(totals[i] / pt - 1) : '';
}
return fmt(totals[i]);
});
const endW = Math.max(...endTexts.map((s) => textWidth(s, 13)), withTotalRow ? textWidth(fmt(grand), 13) : 0, 24) + 14;
const barW = Math.max(40, width - labelW - endW);
const prevMax = variant === 'compare' ? Math.max(0, ...previous.map((d) => totalOf(d))) : 0;
const scaleMax = variant === 'percent' ? 1 : Math.max(1, ...totals, prevMax);
const rowH = variant === 'compare' ? (phone ? 64 : 46) : phone ? 54 : 36;
const rowGap = 10;
const sumGap = 22;
const yOf = (i: number) => i * (rowH + rowGap) + (i >= n ? sumGap - rowGap + 10 : 0);
const tipIndex = shown !== null && shown < count ? shown : null;
const tipX = tipIndex === null ? 0 : round(labelW + (variant === 'percent' ? barW : (totalAt(tipIndex) / scaleMax) * barW));
/** One stacked bar in HTML. share: width of the whole bar from 0 to 1. */
const bar = (vals: number[], share: number, i: number, pale = false, thin = false) => {
const tot = vals.reduce((a, b) => a + b, 0) || 1;
const visible = order.filter((k) => vals[k] > 0);
return (
<span className={cn('relative block min-w-0', thin ? 'h-2' : 'h-7')}>
<span
className={cn('absolute inset-y-0 left-0 flex gap-[2px] overflow-hidden rounded-r-[5px] transition-[width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', anim && 'kitch-grow-x', thin && 'rounded-r-[3px]')}
style={{ width: `${round(Math.max(0, Math.min(1, share)) * 100, 3)}%`, ...delay(100 + i * 70 + (pale ? 0 : 60)) }}
>
{visible.map((k) => {
const s = vals[k] / tot;
const text = variant === 'percent' ? pct(s) : tick(vals[k]);
const px = s * share * barW;
const fits = !thin && segmentLabels && textWidth(text, 11) + 10 <= px;
return (
<span
key={series[k].key}
className="kitch-series flex min-w-0 items-center justify-center overflow-hidden text-[11px] font-medium tabular-nums"
style={{ flexGrow: vals[k], flexBasis: 0, background: seriesColor(k, series[k].color), color: ink(k), opacity: pale ? dim(k) * 0.42 : dim(k) }}
>
{fits ? text : ''}
</span>
);
})}
</span>
</span>
);
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
{variant === 'compare' && periods && (
<p className="-mt-1 mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="h-1.5 w-4 rounded-full bg-[var(--kitch-1)] opacity-40" />
{periods[0]}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="h-2.5 w-4 rounded-[3px] bg-[var(--kitch-1)]" />
{periods[1]}
</span>
</p>
)}
<div ref={wrapRef}>
<ChartSurface {...surface}>
<ul className="relative" style={{ height: yOf(count - 1) + rowH }}>
{Array.from({ length: count }, (_, i) => {
const isSum = i >= n;
const vals = valuesAt(i);
const tot = totalAt(i);
const prev = variant === 'compare' && !isSum ? prevOf(rows[i]) : undefined;
const end = isSum ? fmt(grand) : endTexts[i];
const change = variant === 'compare' && !isSum ? totals[i] / (totalOf(prev) || 1) - 1 : 0;
return (
<li
key={i}
onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
onPointerDown={() => setActive(i)}
className={cn('absolute inset-x-0 grid items-center gap-x-3 transition-opacity duration-200', active !== null && active !== i && 'opacity-60', isSum && "before:absolute before:inset-x-0 before:-top-4 before:h-px before:bg-border before:content-['']")}
style={{ top: yOf(i), height: rowH, gridTemplateColumns: phone ? 'minmax(0,1fr) auto' : `${labelW - 12}px minmax(0,1fr) ${endW - 12}px` }}
>
<span className={cn('truncate text-[13px]', isSum ? 'font-medium text-foreground' : 'text-foreground', active === i && 'font-medium', phone && 'self-end', !phone && variant === 'compare' && 'self-end pb-1')}>{catAxis(i)}</span>
{phone && (
<span className={cn('self-end text-right text-[13px] font-semibold tabular-nums', variant === 'compare' ? (change >= 0 ? 'text-success-foreground' : 'text-destructive') : 'text-foreground')}>
{end}
</span>
)}
<span className={cn('grid min-w-0 gap-1', phone && 'col-span-2 self-start')} style={phone ? undefined : { gridColumn: 2 }}>
{variant === 'compare' && bar(series.map((s) => num(prev, s.key)), totalOf(prev) / scaleMax, i, true, true)}
{bar(vals, variant === 'percent' ? 1 : tot / scaleMax, i)}
</span>
{!phone && (
<span className={cn('text-right text-[13px] tabular-nums', variant === 'compare' && 'self-end pb-1', isSum ? 'font-semibold' : 'font-medium', variant === 'compare' ? (change >= 0 ? 'text-success-foreground' : 'text-destructive') : 'text-foreground', anim && 'kitch-rise')} style={delay(420 + i * 70)}>
{end}
</span>
)}
</li>
);
})}
</ul>
<ChartTooltip open={open} jump={jump} x={tipX} y={tipIndex === null ? 0 : Math.max(0, yOf(tipIndex) - 8)} width={width} offset={12} title={tipIndex === null ? '' : catName(tipIndex)} rows={tipIndex === null ? [] : tipRows(tipIndex)} />
</ChartSurface>
{variant === 'horizontal' && totalRow && (
<dl className={cn('mt-5 grid gap-px overflow-hidden rounded-lg border border-border bg-border text-[12px]', series.length > 2 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2')} style={phone ? undefined : { gridTemplateColumns: `repeat(${series.length + 1}, minmax(0, 1fr))` }}>
{series.map((s, k) => (
<div key={s.key} className="bg-card px-3 py-2.5">
<dt className="flex items-center gap-1.5 truncate text-muted-foreground">
<Swatch color={seriesColor(k, s.color)} />
{s.label}
</dt>
<dd className="mt-1 text-[14px] font-semibold tabular-nums text-foreground">
{fmt(seriesSums[k])} <span className="text-[12px] font-normal text-muted-foreground">{pct(seriesSums[k] / (grand || 1))}</span>
</dd>
</div>
))}
<div className={cn('bg-card px-3 py-2.5', series.length % 2 === 1 && phone && 'col-span-1', series.length % 2 === 0 && phone && 'col-span-2')}>
<dt className="truncate font-medium text-foreground">{t.sum}</dt>
<dd className="mt-1 text-[14px] font-semibold tabular-nums text-foreground">{fmt(grand)}</dd>
</div>
</dl>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Stacked bar chart?
With aiw add @ai-website/stacked-bar-chart. The command puts components/ui/stacked-bar-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 Stacked bar chart have?
4 variants, Columns with totals, 100 % rows, Sorted rows with sum line and Two periods. You pick one with the variant prop, for example variant="percent".
Can I translate the texts of Stacked bar chart?
Yes. Stacked bar chart carries 9 texts of its own in English and German, such as “Total”, “All” and “Share”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Stacked bar chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Stacked bar chart with aiw_components and install it with aiw_add @ai-website/stacked-bar-chart. Name the variant in your prompt, for example variant="columns", and the assistant writes your content straight into the props.