Skip to content
UI Kit
Accent color

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.

4 variantsOne of 8 components in Comparison charts

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

Columns with totalsNew in 1.2

Stacked columns per month, the value in each part that fits, the total on top. Pick a channel to set it on the baseline.

100 % rowsNew in 1.2

Each team as a full row of shares, the ticket count at the end, a total row below that stacks every team into one.

Sorted rows with sum lineNew in 1.2

Absolute rows sorted by total, the total at the end of each row and a sum line that adds up every series.

Two periodsNew in 1.2

This year as the full bar, last year thin and pale above it on the same scale, the change of the total at the end.

Details

What Stacked bar chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    StackedBarVariant

    columnspercenthorizontalcompare

  • datarequired
    Datum[]

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

  • xKeyrequired
    string

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

  • seriesrequired
    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

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
totalEnglish TotalGerman Gesamt
allEnglish AllGerman Alle
shareEnglish ShareGerman Anteil
changeEnglish ChangeGerman Veränderung
categoryEnglish CategoryGerman Kategorie
baseEnglish Set a series on the baselineGerman Eine Reihe an die Grundlinie setzen
baseOnEnglish {series} on the baselineGerman {series} an der Grundlinie
sumEnglish SumGerman Summe
ofEnglish of the totalGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard