Gestapeltes Balkendiagramm
Teile eines Ganzen mit allen Summen ausgeschrieben. Gestapelte Säulen mit Summen, 100-%-Zeilen mit Gesamtzeile, sortierte Zeilen mit Summenzeile und zwei Zeiträume in einer Zeile. Eine in der Legende gewählte Reihe rückt an die Grundlinie.
Teile eines Ganzen, jede Summe ausgeschrieben
Ein gestapeltes Balkendiagramm zeigt, wie sich eine Summe in Teile gliedert, Kanäle je Monat oder Teams nach Anteil. Dieses schreibt jede Summe aus und lässt die Legende jede Reihe an die Grundlinie setzen, was der einfache Stapelmodus des Balkendiagramms nicht kann, jeder Teil wird so für sich vergleichbar.
„Säulen mit Summen“ stapelt Säulen je Monat mit dem Wert in jedem Teil, der Platz hat, und der Summe obenauf, ein Kanal lässt sich an die Grundlinie setzen. „100-%-Zeilen“ zeigt jedes Team als volle Zeile aus Anteilen mit der Ticketzahl am Ende und einer Gesamtzeile darunter, die alle Teams in eine stapelt. „Sortierte Zeilen mit Summenzeile“ sortiert absolute Zeilen nach Summe, mit der Summe am Ende jeder Zeile und einer Summenzeile, die jede Reihe aufaddiert. „Zwei Zeiträume“ zeichnet dieses Jahr als vollen Balken und das Vorjahr schmal und blass darüber auf derselben Skala, mit der Veränderung der Summe am Ende.
Halte Stapel bei vier oder fünf Reihen, darüber lassen sich die mittleren Teile kaum vergleichen. Setz die wichtigste Reihe an die Grundlinie, nur sie liest sich genau. Sortiere Zeilen nach Summe, außer die Reihenfolge bedeutet selbst etwas, wie bei Monaten.
Variants
Details
Was Gestapeltes Balkendiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Vergleichs-Charts
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/stacked-bar-chart.tsx
- Bringt mit
- utilskitchartchart-figure
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 9 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StackedBarChartProps aus components/ui/stacked-bar-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalStackedBarVariantcolumnspercenthorizontalcompare
dataPflichtDatum[]Rows, one per category, e.g. { month: '2026-03', online: 21400, store: 9800 }
xKeyPflichtstringKey of the category. "2026-03" is read as a month and formatted per language.
seriesPflichtChartSeries[]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
Eingebaute Texte
Diese Texte schreibt Gestapeltes Balkendiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 9 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| total | Englisch Total | Deutsch Gesamt |
| all | Englisch All | Deutsch Alle |
| share | Englisch Share | Deutsch Anteil |
| change | Englisch Change | Deutsch Veränderung |
| category | Englisch Category | Deutsch Kategorie |
| base | Englisch Set a series on the baseline | Deutsch Eine Reihe an die Grundlinie setzen |
| baseOn | Englisch {series} on the baseline | Deutsch {series} an der Grundlinie |
| sum | Englisch Sum | Deutsch Summe |
| of | Englisch of the total | Deutsch vom Gesamtwert |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Gestapeltes Balkendiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 453 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Gestapeltes Balkendiagramm?
Mit aiw add @ai-website/stacked-bar-chart. Der Befehl legt die Datei components/ui/stacked-bar-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Gestapeltes Balkendiagramm?
4 Varianten, Säulen mit Summen, 100-%-Zeilen, Sortierte Zeilen mit Summenzeile und Zwei Zeiträume. Du wählst sie mit der Prop variant, zum Beispiel variant="percent".
Kann ich die Texte von Gestapeltes Balkendiagramm übersetzen?
Ja. Gestapeltes Balkendiagramm bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Gesamt“, „Alle“ und „Anteil“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Gestapeltes Balkendiagramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Gestapeltes Balkendiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/stacked-bar-chart. Nenn im Prompt die Variante, etwa variant="columns", dann schreibt der Assistent deine Inhalte direkt in die Props.