Skip to content
UI Kit
Accent color

Proportion chart

Shares that add up to 100%, as stacked rows, columns that show the mix over time, a marimekko where width is weight, or survey answers around a neutral middle.

One of 5 components in Comparison charts

4 variants

Variants

Stacked rows

One 100% bar per row, the same parts in the same order, shares inside where they fit. The legend brings one part forward.

Open in Studio

Mix over time

One 100% column per month, so a shift in the mix shows even when the total grows.

Open in Studio

Marimekko

Column width is the size of a segment, the parts inside are its mix, so a big segment weighs as much as it should.

Open in Studio

Survey answers

Five answers from disagree to agree, the neutral middle on one line, agreement to the right and the net share beside each statement.

Open in Studio

Details

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

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/proportion-chart.tsx
Brings along
utilskitchartchart-figure
npm packages
None
Languages
English and German, 4 texts of its own

Props

The ProportionChartProps interface from components/ui/proportion-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    ProportionVariant

    stackedcolumnsmarimekkolikert

  • partsrequired
    string[]

    Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first)

  • rowsrequired
    ProportionRow[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format of the absolute values in tooltips and table

Built-in texts

Proportion chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
itemEnglish RowGerman Zeile
totalEnglish TotalGerman Gesamt
netEnglish Net agreementGerman Zustimmung netto
sizeEnglish Width is the size of each segmentGerman Die Breite ist die Größe jedes Segments

Accessibility

Read from the source of the component.

  • Proportion chart handles the keys Escape, Arrow up, Arrow down, Arrow left and Arrow right itself.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 256 lines
components/ui/proportion-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, Swatch, numberFormatter, seriesColor, textWidth, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Shares that add up to 100 %.
 *   stacked    one 100 % bar per row, the same parts in the same order
 *   columns    one 100 % column per period, how the mix moves over time
 *   marimekko  column width is the size of a segment, the parts inside it
 *              their share, so both the mix and the weight are honest
 *   likert     survey answers from disagree to agree around a neutral
 *              middle, agreement to the right, the net share beside
 *
 * Up to four parts in the slot colors (they touch only their neighbours,
 * the adjacent pair check holds), parted by a 2 px surface gap. A share
 * label sits inside a part only where it fits.
 */

export type ProportionVariant = 'stacked' | 'columns' | 'marimekko' | 'likert';

export interface ProportionRow {
  label: string;
  /** One value per part (or per answer for likert, disagree first) */
  values: number[];
}

export interface ProportionChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: ProportionVariant;
  /** Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first) */
  parts: string[];
  rows: ProportionRow[];
  /** Value format of the absolute values in tooltips and table */
  format?: Intl.NumberFormatOptions;
  className?: string;
}

const TEXTS = {
  en: { item: 'Row', total: 'Total', net: 'Net agreement', size: 'Width is the size of each segment' },
  de: { item: 'Zeile', total: 'Gesamt', net: 'Zustimmung netto', size: 'Die Breite ist die Größe jedes Segments' },
};

const LIKERT = ['color-mix(in oklab, var(--kitfg-down) 80%, var(--card))', 'color-mix(in oklab, var(--kitfg-down) 38%, var(--card))', 'color-mix(in oklab, var(--muted-foreground) 30%, var(--card))', 'color-mix(in oklab, var(--primary) 45%, var(--card))', 'var(--primary)'];
// Text on a part: dark ink on light tints, the surface color on the two full tones
const INK_LIKERT = ['var(--pc-dark)', 'var(--foreground)', 'var(--foreground)', 'var(--foreground)', 'var(--primary-foreground)'];

/**
 * 100 % shares: stacked rows, columns over time, a marimekko or survey answers.
 *   <ProportionChart variant="likert" parts={['Disagree strongly', …, 'Agree strongly']} rows={[{ label: 'Easy to book', values: [4, 9, 18, 41, 28] }]} />
 */
export function ProportionChart(props: ProportionChartProps) {
  const { variant = 'stacked', parts, rows, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const signedPct = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + pct(Math.abs(n));
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const [focus, setFocus] = useState<number | null>(null);
  const name = label ?? title ?? '';
  const phone = width < 520;
  const P = variant === 'likert' ? parts.slice(0, 5) : parts.slice(0, 4);
  const colorOf = (k: number) => (variant === 'likert' ? LIKERT[k] : seriesColor(k));
  const inkOf = (k: number) => (variant === 'likert' ? INK_LIKERT[k] : k === 0 ? 'var(--primary-foreground)' : k === 1 ? 'var(--background)' : 'var(--foreground)');
  const sums = rows.map((r) => r.values.slice(0, P.length).reduce((a, v) => a + Math.max(0, v), 0) || 1);
  const shares = rows.map((r, i) => r.values.slice(0, P.length).map((v) => Math.max(0, v) / sums[i]));
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const xf = xFormatters(intl, rows[0]?.label);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    let next: number | null = cur;
    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(rows.length - 1, cur + 1);
    else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
    else if (e.key === 'Escape') next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };
  const rowName = (r: ProportionRow) => (variant === 'columns' ? xf.tooltip(r.label) : r.label);
  const live = active !== null && rows[active] ? `${rowName(rows[active])}. ${P.map((p, k) => `${p} ${pct(shares[active][k])}`).join(', ')}` : '';
  const table = <ChartTable caption={name} head={[t.item, ...P, t.total]} rows={rows.map((r, i) => [rowName(r), ...P.map((_, k) => `${pct(shares[i][k])} (${fmt(r.values[k] ?? 0)})`), fmt(sums[i])])} />;
  const legend = <PickLegend className="mb-4" active={focus} onActive={setFocus} items={P.map((p, k) => ({ label: p, key: <Swatch color={colorOf(k)} /> }))} />;
  const dim = (k: number) => (focus !== null && focus !== k ? 0.22 : 1);
  const surfaceProps = { label: name, summary: summary ?? '', labels, live, table, onKeyDown, onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0), onBlur: () => setActive(null), onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null) };

  /* ---------------- stacked rows and likert ---------------- */
  if (variant === 'stacked' || variant === 'likert') {
    const labelW = phone ? 0 : Math.min(220, Math.max(...rows.map((r) => textWidth(r.label, 13))) + 20);
    const netW = variant === 'likert' && !phone ? 64 : 0;
    // Likert: neutral centred on the middle line, disagreement to the left
    const left = (i: number) => (variant === 'likert' ? 0.5 - shares[i][0] - shares[i][1] - shares[i][2] / 2 : 0);
    const minLeft = variant === 'likert' ? Math.min(...rows.map((_, i) => left(i))) : 0;
    const maxRight = variant === 'likert' ? Math.max(...rows.map((_, i) => left(i) + 1)) : 1;
    const span = maxRight - minLeft;
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
          {legend}
          <div ref={wrapRef}>
            <ChartSurface {...surfaceProps}>
              <ul className="relative grid gap-3">
                {variant === 'likert' && (
                  <span aria-hidden className="pointer-events-none absolute inset-y-0 w-px bg-[var(--kitch-axis)]" style={{ left: `calc(${labelW}px + (100% - ${labelW + netW}px) * ${round((0.5 - minLeft) / span, 4)})` }} />
                )}
                {rows.map((r, i) => {
                  const net = variant === 'likert' ? shares[i][3] + shares[i][4] - shares[i][0] - shares[i][1] : 0;
                  let acc = (left(i) - minLeft) / span;
                  return (
                    <li key={r.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className={cn('grid items-center gap-x-3 gap-y-1.5 rounded-md transition-opacity duration-200', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : '', active !== null && active !== i && 'opacity-60')} style={phone ? undefined : { gridTemplateColumns: `${labelW - 12}px minmax(0,1fr)${netW ? ` ${netW - 12}px` : ''}` }}>
                      <span className={cn('truncate text-[13px] text-foreground', active === i && 'font-medium')}>{r.label}</span>
                      {phone && variant === 'likert' && <span className="text-right text-[12px] font-medium tabular-nums text-foreground">{signedPct(net)}</span>}
                      <span className={cn('relative h-7 min-w-0', phone && 'col-span-2')}>
                        {shares[i].map((s, k) => {
                          const x = acc;
                          acc += s / span;
                          const w = s / span;
                          const text = pct(s);
                          const fits = s > 0 && textWidth(text, 11) + 10 <= w * (width - labelW - netW);
                          return (
                            <span
                              key={k}
                              className={cn('kitch-series absolute inset-y-0 flex items-center justify-center overflow-hidden text-[11px] font-medium tabular-nums', anim && 'kitch-grow-x', k === 0 && 'rounded-l-[5px]', k === P.length - 1 && 'rounded-r-[5px]')}
                              style={{ left: `calc(${round(x * 100, 3)}% + ${k ? 1 : 0}px)`, width: `calc(${round(w * 100, 3)}% - ${k && k < P.length - 1 ? 2 : 1}px)`, background: colorOf(k), color: inkOf(k), opacity: dim(k), ...d(120 + i * 70 + k * 40) }}
                            >
                              {fits ? text : ''}
                            </span>
                          );
                        })}
                      </span>
                      {!phone && variant === 'likert' && <span className="text-right text-[13px] font-semibold tabular-nums text-foreground">{signedPct(net)}</span>}
                    </li>
                  );
                })}
              </ul>
              {variant === 'likert' && <p className="mt-3 text-right text-[12px] text-muted-foreground">{t.net}</p>}
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- columns over time and marimekko ---------------- */
  const height = phone ? 260 : 300;
  const top = 6;
  const bottom = height - 40;
  const plotH = bottom - top;
  const left = 36;
  const right = width - 4;
  const totalAll = sums.reduce((a, v) => a + v, 0) || 1;
  const gap = variant === 'marimekko' ? 3 : 0;
  const colW = variant === 'columns' ? Math.min(44, ((right - left) / rows.length) * 0.62) : 0;
  const band = (right - left) / Math.max(1, rows.length);
  let xAcc = left;
  const cols = rows.map((r, i) => {
    if (variant === 'marimekko') {
      const w = (sums[i] / totalAll) * (right - left - gap * (rows.length - 1));
      const c = { x: xAcc, w };
      xAcc += w + gap;
      return c;
    }
    return { x: left + band * i + (band - colW) / 2, w: colW };
  });
  const pick = (clientX: number, el: HTMLElement) => {
    const b = el.getBoundingClientRect();
    const px = (clientX - b.left) * (width / (b.width || width));
    const i = cols.findIndex((c) => px >= c.x - (variant === 'columns' ? (band - colW) / 2 : gap / 2) && px <= c.x + c.w + (variant === 'columns' ? (band - colW) / 2 : gap / 2));
    setActive(i >= 0 ? i : null);
  };
  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
        {legend}
        <div ref={wrapRef}>
          <ChartSurface {...surfaceProps} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)}>
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              {[0, 0.25, 0.5, 0.75, 1].map((q) => (
                <g key={q}>
                  <line x1={left} x2={right} y1={round(bottom - q * plotH)} y2={round(bottom - q * plotH)} stroke={q === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
                  <text x={left - 8} y={round(bottom - q * plotH)} dy="0.34em" textAnchor="end" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(q)}</text>
                </g>
              ))}
              {rows.map((r, i) => {
                const c = cols[i];
                let yAcc = bottom;
                const on = active === i;
                const lab = variant === 'columns' ? xf.axis(r.label) : r.label;
                const labFits = textWidth(lab, 11) <= (variant === 'columns' ? band : c.w) - 4;
                return (
                  <g key={r.label} opacity={active !== null && !on ? 0.55 : 1} className="transition-opacity duration-200">
                    {shares[i].map((s, k) => {
                      const h = s * plotH;
                      const y = yAcc - h;
                      yAcc = y;
                      const inner = Math.max(0, h - (k < P.length - 1 ? 2 : 0));
                      const text = pct(s);
                      const fits = textWidth(text, 11) + 8 <= c.w && inner >= 16;
                      return (
                        <g key={k} className="kitch-series" opacity={dim(k)}>
                          <rect x={round(c.x)} y={round(y + (k < P.length - 1 ? 2 : 0))} width={round(c.w)} height={round(inner)} rx={k === P.length - 1 ? 3 : 0} fill={colorOf(k)} className={anim ? 'kitch-grow-y' : undefined} style={{ ...d(100 + i * 70 + k * 50), transformOrigin: `${round(c.x + c.w / 2)}px ${bottom}px`, transformBox: 'view-box' }} />
                          {fits && (
                            <text x={round(c.x + c.w / 2)} y={round(y + h / 2 + 1)} dy="0.34em" textAnchor="middle" className={cn('text-[11px] font-medium tabular-nums', anim && 'kitch-fade')} fill={inkOf(k)} style={d(600 + i * 70)}>
                              {text}
                            </text>
                          )}
                        </g>
                      );
                    })}
                    {labFits && (
                      <text x={round(c.x + c.w / 2)} y={bottom + 18} textAnchor="middle" className={cn('text-[11px]', on && 'fill-foreground font-medium')} fill="var(--muted-foreground)">{lab}</text>
                    )}
                    {variant === 'marimekko' && labFits && (
                      <text x={round(c.x + c.w / 2)} y={bottom + 33} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(sums[i] / totalAll)}</text>
                    )}
                  </g>
                );
              })}
            </svg>
          </ChartSurface>
          {variant === 'marimekko' && (
            <p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
              <span>{t.size}</span>
              {/* Segments too narrow for their name under the column are named here, nothing goes missing */}
              {rows.map((r, i) =>
                textWidth(r.label, 11) > cols[i].w - 4 ? (
                  <span key={r.label} className="tabular-nums">
                    <span className="text-foreground">{r.label}</span> {pct(sums[i] / totalAll)}
                  </span>
                ) : null,
              )}
            </p>
          )}
          {active !== null && rows[active] && (
            <p aria-hidden className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]">
              <span className="font-medium text-foreground">{rowName(rows[active])}</span>
              {P.map((p, k) => (
                <span key={p} className="inline-flex items-center gap-1.5 text-muted-foreground">
                  <Swatch color={colorOf(k)} />
                  {p}
                  <span className="font-medium tabular-nums text-foreground">{pct(shares[active][k])}</span>
                </span>
              ))}
            </p>
          )}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Frequent questions

How do I install Proportion chart?

With aiw add @ai-website/proportion-chart. The command puts components/ui/proportion-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.

Which variants does Proportion chart have?

4 variants, Stacked rows, Mix over time, Marimekko and Survey answers. You pick one with the variant prop, for example variant="columns".

Can I translate the texts of Proportion chart?

Yes. Proportion chart carries 4 texts of its own in English and German, such as “Row”, “Total” and “Net agreement”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Proportion chart?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Proportion chart with aiw_components and install it with aiw_add @ai-website/proportion-chart. Name the variant in your prompt, for example variant="stacked", and the assistant writes your content straight into the props.

Can I adjust Proportion chart without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ProportionChart code to copy.

Often searched

Jump to

Copied to the clipboard