Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 8 Components in Vergleichs-Charts

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

Säulen mit SummenNeu in 1.2

Gestapelte Säulen je Monat, der Wert in jedem Teil, der Platz hat, die Summe obenauf. Ein Kanal lässt sich an die Grundlinie setzen.

100-%-ZeilenNeu in 1.2

Jedes Team als volle Zeile aus Anteilen, die Ticketzahl am Ende, darunter eine Gesamtzeile, die alle Teams in eine stapelt.

Sortierte Zeilen mit SummenzeileNeu in 1.2

Absolute Zeilen nach Summe sortiert, die Summe am Ende jeder Zeile und eine Summenzeile, die jede Reihe aufaddiert.

Zwei ZeiträumeNeu in 1.2

Dieses Jahr als voller Balken, das Vorjahr schmal und blass darüber auf derselben Skala, die Veränderung der Summe am Ende.

Details

Was Gestapeltes Balkendiagramm mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    StackedBarVariant

    columnspercenthorizontalcompare

  • dataPflicht
    Datum[]

    Rows, one per category, e.g. { month: '2026-03', online: 21400, store: 9800 }

  • xKeyPflicht
    string

    Key of the category. "2026-03" is read as a month and formatted per language.

  • seriesPflicht
    ChartSeries[]

    The parts, in slot order (accent, grey, two accent tones). At most four.

  • previousoptional
    Datum[]

    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']

  • formatoptional
    Intl.NumberFormatOptions

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

  • xFormatoptional
    Intl.DateTimeFormatOptions
  • tooltipXFormatoptional
    Intl.DateTimeFormatOptions
  • segmentLabelsoptional
    boolean

    Values or shares inside the parts, only where they fit. Default true.

  • totalRowoptional
    boolean

    percent: a total row that stacks all rows. horizontal: the sum line per series. Default true.

  • sortoptional
    boolean

    horizontal and compare: sort rows by total, largest first. Default true for horizontal.

  • defaultFocusoptional
    number | null

    The series set on the baseline at the start (index), none by default

  • heightoptional
    number

    columns: plot height in px, axis labels included. Default 300, 260 on phones.

  • xLabeloptional
    string

    Header 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
totalEnglisch TotalDeutsch Gesamt
allEnglisch AllDeutsch Alle
shareEnglisch ShareDeutsch Anteil
changeEnglisch ChangeDeutsch Veränderung
categoryEnglisch CategoryDeutsch Kategorie
baseEnglisch Set a series on the baselineDeutsch Eine Reihe an die Grundlinie setzen
baseOnEnglisch {series} on the baselineDeutsch {series} an der Grundlinie
sumEnglisch SumDeutsch Summe
ofEnglisch of the totalDeutsch 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
components/ui/stacked-bar-chart.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert