Skip to content
UI Kit
Accent color

Combo chart

Columns, an area and lines on one time axis. A rate on a second scale with its own titled key, actual against plan, columns against a target and a forecast that turns dashed with a band around it.

4 variantsOne of 10 components in Charts

Two measures, one time axis

A combo chart puts columns, an area and lines on one time axis, so two related measures read together, such as nights sold and occupancy. A second scale carries its own title and shares the grid, which keeps both readable without guessing which line belongs to which axis.

Columns and a rate shows nights sold as columns on the left scale and occupancy as a line on the right one. Actual against plan draws weekly active users as an area and the plan as a dashed line on the same scale, where the area rises above the line the plan is beaten. Against a target sets columns against a target step per month, columns that reach it keep the accent and the count of months on target sits on top. Forecast shows revenue as columns with its three month average as a line, both turn hatched and dashed from October with the expected range around them.

Use a second scale only when the two measures have different units. Mark the forecast clearly, hatching and dashes tell people what is not yet real. Name the target in the title, then the chart answers its question at a glance.

Variants

Columns and a rateNew in 1.2

Nights sold as columns on the left scale, occupancy as a line on the right one. Both scales share the grid and carry their title.

Actual against planNew in 1.2

Weekly active users as an area, the plan as a dashed line on the same scale. Where the area rises above the line, the plan is beaten.

Against a targetNew in 1.2

Columns against a target step per month. Columns that reach it keep the accent, the count of months on target sits on top.

ForecastNew in 1.2

Revenue as columns and its three month average as a line. From October both turn into a forecast, hatched and dashed, with the expected range around it.

Details

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

Category
Charts
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/combo-chart.tsx
Brings along
utilskitchartchart-figure
npm packages
None
Languages
English and German, 9 texts of its own
Studio
Adjust in code

Props

The ComboChartProps interface from components/ui/combo-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
    ComboVariant

    bar-linearea-linetargetforecast

  • datarequired
    Datum[]

    Rows, one per period, e.g. { month: '2026-03', bookings: 412, rate: 0.71 }

  • xKeyrequired
    string

    Key of the period. "2026-03" and "2026-03-14" are read as dates.

  • seriesrequired
    ComboSeries[]

    Up to two columns, one area and two lines

  • formatoptional
    Intl.NumberFormatOptions

    Format of the left scale, e.g. { style: 'currency', currency: 'EUR' }

  • rightFormatoptional
    Intl.NumberFormatOptions

    Format of the right scale, e.g. { style: 'percent' }

  • leftTitleoptional
    string

    Titles of the scales, shown above them with their key

  • rightTitleoptional
    string
  • targetoptional
    { value?: number; key?: string; label: string }

    target: a fixed target (value) or one per row (key), drawn on the left scale

  • forecastFromoptional
    string

    forecast: the first period that is a forecast

  • rangeoptional
    { low: string; high: string; label: string }

    forecast: keys of the low and high end of the band around the forecast line

  • xFormatoptional
    Intl.DateTimeFormatOptions
  • tooltipXFormatoptional
    Intl.DateTimeFormatOptions
  • heightoptional
    number

    Plot height in px, axis labels included. Default 320, 280 on phones.

  • xLabeloptional
    string

    Header of the first table column

Built-in texts

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

Show all 9 texts
hitsEnglish {n} of {total} periods on targetGerman {n} von {total} Zeiträumen im Ziel
forecastEnglish ForecastGerman Prognose
actualEnglish ActualGerman Ist
targetEnglish TargetGerman Ziel
rangeEnglish RangeGerman Spanne
periodEnglish PeriodGerman Zeitraum
reachedEnglish reachedGerman erreicht
missedEnglish belowGerman darunter
betweenEnglish {lo} to {hi}German {lo} bis {hi}

Accessibility

Read from the source of the component.

  • 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, 413 lines
components/ui/combo-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, Swatch, ChartSurface, ChartTable, ChartTooltip, GlideDot, barPath, fill, linePath, niceTicks, numberFormatter, seriesOpacity, textWidth, tickFormatter, useActive, useEntrance, useIntlLocale, useShown, useWidth, xFormatters, type ChartA11y, type ChartSeries, type Datum, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Two kinds of marks on one time axis.
 *   bar-line   columns on the left scale, a rate as a line on the right scale,
 *              both scales titled with their key so neither is read on the wrong side
 *   area-line  an area and a line on one scale, e.g. actual against plan
 *   target     columns against a target per period (a line or steps), columns
 *              that reach it keep the accent, the count of hits on top
 *   forecast   columns and a line that turn pale and dashed from a given
 *              period on, with a band from low to high around the forecast
 *
 * Colors by role: lines take the accent and the grey first, an area the
 * next of the two, columns the pale accent tones, so the line always stays
 * the loudest mark. A second scale is only drawn where a series asks for it.
 */

export type ComboVariant = 'bar-line' | 'area-line' | 'target' | 'forecast';

export interface ComboSeries extends ChartSeries {
  /** How the series is drawn */
  type: 'bar' | 'line' | 'area';
  /** Which scale it is read on. Default left. */
  axis?: 'left' | 'right';
}

export interface ComboChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: ComboVariant;
  /** Rows, one per period, e.g. { month: '2026-03', bookings: 412, rate: 0.71 } */
  data: Datum[];
  /** Key of the period. "2026-03" and "2026-03-14" are read as dates. */
  xKey: string;
  /** Up to two columns, one area and two lines */
  series: ComboSeries[];
  /** Format of the left scale, e.g. { style: 'currency', currency: 'EUR' } */
  format?: Intl.NumberFormatOptions;
  /** Format of the right scale, e.g. { style: 'percent' } */
  rightFormat?: Intl.NumberFormatOptions;
  /** Titles of the scales, shown above them with their key */
  leftTitle?: string;
  rightTitle?: string;
  /** target: a fixed target (value) or one per row (key), drawn on the left scale */
  target?: { value?: number; key?: string; label: string };
  /** forecast: the first period that is a forecast */
  forecastFrom?: string;
  /** forecast: keys of the low and high end of the band around the forecast line */
  range?: { low: string; high: string; label: string };
  xFormat?: Intl.DateTimeFormatOptions;
  tooltipXFormat?: Intl.DateTimeFormatOptions;
  /** Plot height in px, axis labels included. Default 320, 280 on phones. */
  height?: number;
  /** Header of the first table column */
  xLabel?: string;
  className?: string;
}

const TEXTS = {
  en: { hits: '{n} of {total} periods on target', forecast: 'Forecast', actual: 'Actual', target: 'Target', range: 'Range', period: 'Period', reached: 'reached', missed: 'below', between: '{lo} to {hi}' },
  de: { hits: '{n} von {total} Zeiträumen im Ziel', forecast: 'Prognose', actual: 'Ist', target: 'Ziel', range: 'Spanne', period: 'Zeitraum', reached: 'erreicht', missed: 'darunter', between: '{lo} bis {hi}' },
};

const GAP = 2;

/**
 * Columns, areas and lines on one time axis, with a second scale, a target or a forecast.
 *   <ComboChart variant="bar-line" data={rows} xKey="month" series={[{ key: 'nights', label: 'Nights', type: 'bar' }, { key: 'rate', label: 'Occupancy', type: 'line', axis: 'right' }]} rightFormat={{ style: 'percent' }} />
 */
export function ComboChart(props: ComboChartProps) {
  const { variant = 'bar-line', data, xKey, series: allSeries, format, rightFormat, leftTitle, rightTitle, target, forecastFrom, range, xFormat, tooltipXFormat, 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 fl = numberFormatter(intl, format);
  const fr = numberFormatter(intl, rightFormat ?? format);
  const tl = tickFormatter(intl, format);
  const tr = tickFormatter(intl, rightFormat ?? format);
  const xf = xFormatters(intl, data[0]?.[xKey], xFormat, tooltipXFormat);
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const n = data.length;
  const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(n);
  const { shown, open, jump } = useShown(active);
  const [focus, setFocus] = useState<number | null>(null);
  const phone = width < 520;
  const height = heightProp ?? (phone ? 280 : 320);
  const name = label ?? title ?? '';
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const num = (d: Datum | undefined, k?: string) => (d && k && typeof d[k] === 'number' ? (d[k] as number) : null);

  // Series by role, colors by role
  const bars = allSeries.filter((s) => s.type === 'bar').slice(0, 2);
  const lines = allSeries.filter((s) => s.type === 'line').slice(0, 2);
  const area = allSeries.find((s) => s.type === 'area');
  const series = [...bars, ...(area ? [area] : []), ...lines];
  const lineColors = ['var(--kitch-1)', 'var(--kitch-2)'];
  const colorOf = (s: ComboSeries) => {
    if (s.color) return s.color;
    // An area is the main measure where there is one, the lines then compare against it
    if (s.type === 'area') return 'var(--kitch-1)';
    if (s.type === 'line') return lineColors[Math.min(1, lines.indexOf(s) + (area ? 1 : 0))];
    const k = bars.indexOf(s);
    return lines.length || area ? (k ? 'var(--kitch-4)' : 'var(--kitch-3)') : k ? 'var(--kitch-3)' : 'var(--kitch-1)';
  };
  const shapeOf = (s: ComboSeries): TooltipRow['shape'] => (s.type === 'bar' ? 'rect' : 'line');
  const onRight = (s: ComboSeries) => s.axis === 'right';
  const hasRight = series.some(onRight);

  // Forecast split
  const fIndex = forecastFrom !== undefined ? data.findIndex((d) => String(d[xKey]) === forecastFrom) : -1;
  const isForecast = (i: number) => fIndex >= 0 && i >= fIndex;

  // Targets
  const targetAt = (i: number) => (target ? (target.key ? num(data[i], target.key) : (target.value ?? null)) : null);
  const barVal = (i: number) => (bars[0] ? num(data[i], bars[0].key) : null);
  const hit = (i: number) => {
    const tv = targetAt(i);
    const v = barVal(i);
    return tv !== null && v !== null && v >= tv;
  };
  const hits = data.filter((_, i) => hit(i)).length;

  // Scales
  const leftVals = data.flatMap((d, i) => [...series.filter((s) => !onRight(s)).map((s) => num(d, s.key)), targetAt(i), range ? num(d, range.high) : null, range ? num(d, range.low) : null]).filter((v): v is number => v !== null);
  const rightVals = data.flatMap((d) => series.filter(onRight).map((s) => num(d, s.key))).filter((v): v is number => v !== null);
  const lMaxV = Math.max(1e-9, ...leftVals);
  const rMax = Math.max(1e-9, ...rightVals);
  /*
   * Both scales share the grid lines: the same number of steps from zero.
   * The count is picked so neither scale runs far past its data (a rate
   * should end at 100 %, not at 150 %).
   */
  const stepFor = (raw: number) => {
    const mag = 10 ** Math.floor(Math.log10(raw));
    return ([1, 2, 2.5, 3, 4, 5, 10].find((m) => m * mag >= raw * 0.999999) ?? 10) * mag;
  };
  let nl = niceTicks(Math.min(0, ...leftVals), lMaxV, 5);
  let rightTicks: number[] = nl.ticks.map(() => 0);
  if (hasRight) {
    let best = { d: 4, waste: Infinity, sL: 1, sR: 1 };
    for (let d = 3; d <= 6; d++) {
      const sL = stepFor(lMaxV / d);
      const sR = stepFor(rMax / d);
      const waste = Math.max((d * sL) / lMaxV, (d * sR) / rMax) + Math.abs(d - 5) * 0.01;
      if (waste < best.waste) best = { d, waste, sL, sR };
    }
    const ticks = Array.from({ length: best.d + 1 }, (_, i) => parseFloat((i * best.sL).toPrecision(12)));
    nl = { min: 0, max: ticks[ticks.length - 1], ticks };
    rightTicks = Array.from({ length: best.d + 1 }, (_, i) => parseFloat((i * best.sR).toPrecision(12)));
  }
  const lTickTexts = nl.ticks.map(tl);
  const rTickTexts = rightTicks.map(tr);
  const left = Math.max(...lTickTexts.map((s) => textWidth(s, 11)), 12) + 12;
  const right = width - (hasRight ? Math.max(...rTickTexts.map((s) => textWidth(s, 11)), 12) + 12 : 6);
  const top = leftTitle || rightTitle || variant === 'target' ? 34 : 14;
  const bottom = height - 26;
  const band = (right - left) / Math.max(1, n);
  const cx = (i: number) => round(left + band * (i + 0.5));
  const Y = (v: number) => round(bottom - ((v - nl.min) / (nl.max - nl.min || 1)) * (bottom - top));
  const rTop = rightTicks[rightTicks.length - 1] || 1;
  const YR = (v: number) => round(bottom - (v / rTop) * (bottom - top));
  const yScale = (s: ComboSeries) => (onRight(s) ? YR : Y);

  const thickAll = Math.min(36, band * 0.62);
  const thick = bars.length > 1 ? Math.max(3, (thickAll - GAP) / 2) : thickAll;
  const catLabels = data.map((d) => xf.axis(d[xKey]));
  const widest = Math.max(...catLabels.map((s) => textWidth(s, 11)), 1) + 10;
  const every = Math.max(1, Math.ceil(widest / Math.max(1, band)));

  const fmtOf = (s: ComboSeries) => (onRight(s) ? fr : fl);
  const rowsAt = (i: number): TooltipRow[] => {
    const d = data[i];
    const out: TooltipRow[] = series.map((s) => {
      const v = num(d, s.key);
      return { key: s.key, label: s.label, value: v === null ? ct.noData : fmtOf(s)(v), color: colorOf(s), shape: shapeOf(s), dashed: s.dashed || (isForecast(i) && s.type === 'line') };
    });
    const tv = targetAt(i);
    if (target && tv !== null) out.push({ key: '__target', label: target.label, value: `${fl(tv)} · ${hit(i) ? t.reached : t.missed}`, color: 'var(--foreground)', shape: 'line', dashed: !target.key });
    if (range && isForecast(i)) {
      const lo = num(d, range.low);
      const hi = num(d, range.high);
      if (lo !== null && hi !== null) out.push({ key: '__range', label: range.label, value: fill(t.between, { lo: fl(lo), hi: fl(hi) }) });
    }
    return out;
  };
  const titleAt = (i: number) => `${xf.tooltip(data[i][xKey])}${isForecast(i) ? ` · ${t.forecast}` : ''}`;
  const live = active !== null && active < n ? `${titleAt(active)}. ${rowsAt(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
  const auto = series
    .map((s) => {
      const vals = data.map((d) => num(d, s.key)).filter((v): v is number => v !== null);
      return vals.length ? `${s.label} ${fmtOf(s)(vals[0])} → ${fmtOf(s)(vals[vals.length - 1])}` : '';
    })
    .filter(Boolean)
    .join(', ');
  const table = (
    <ChartTable
      caption={name}
      head={[xLabel ?? t.period, ...series.map((s) => s.label), ...(target ? [target.label] : []), ...(range ? [range.label] : [])]}
      rows={data.map((d, i) => [
        titleAt(i),
        ...series.map((s) => {
          const v = num(d, s.key);
          return v === null ? ct.noData : fmtOf(s)(v);
        }),
        ...(target ? [targetAt(i) === null ? ct.noData : fl(targetAt(i)!)] : []),
        ...(range ? [num(d, range.low) === null ? '' : fill(t.between, { lo: fl(num(d, range.low)!), hi: fl(num(d, range.high) ?? 0) })] : []),
      ])}
    />
  );

  const pick = (clientX: number, el: HTMLElement) => {
    const b = el.getBoundingClientRect();
    const px = (clientX - b.left) * (width / (b.width || width));
    setActive(Math.max(0, Math.min(n - 1, Math.floor((px - left) / band))));
  };

  // Line and area paths, split at the forecast
  const pts = (s: ComboSeries, from: number, to: number) => {
    const out: [number, number][] = [];
    for (let i = from; i <= to && i < n; i++) {
      const v = num(data[i], s.key);
      if (v !== null) out.push([cx(i), yScale(s)(v)]);
    }
    return out;
  };
  const cut = fIndex >= 0 ? fIndex - 1 : n - 1;

  const legendItems = [
    ...series.map((s) => ({ label: s.label, color: colorOf(s), shape: shapeOf(s), dashed: s.dashed })),
    ...(target ? [{ label: target.label, color: 'var(--foreground)', shape: 'line' as const, dashed: !target.key }] : []),
    ...(range ? [{ label: range.label, color: 'color-mix(in oklab, var(--primary) 22%, transparent)', shape: 'rect' as const, dashed: false }] : []),
  ];
  const sIndex = (s: ComboSeries) => series.indexOf(s);
  const op = (s: ComboSeries) => seriesOpacity(sIndex(s), focus !== null && focus < series.length ? focus : null);

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        <div className="mb-4 flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
          <PickLegend active={focus} onActive={setFocus} items={legendItems.map((it) => ({ label: it.label, key: <Swatch color={it.color} shape={it.shape} dashed={it.dashed} /> }))} />
          {variant === 'target' && target && (
            <p className="text-[12px] text-muted-foreground">
              <span className="font-semibold tabular-nums text-foreground">{fill(t.hits, { n: String(hits), total: String(data.filter((_, i) => targetAt(i) !== null).length) })}</span>
            </p>
          )}
        </div>
        <div ref={wrapRef}>
          <ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={onFocus} onBlur={onBlur} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              <defs>
                <pattern id={`combo-hatch-${variant}`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                  <rect width="6" height="6" fill="color-mix(in oklab, var(--primary) 14%, var(--card))" />
                  <line x1="0" y1="0" x2="0" y2="6" stroke="color-mix(in oklab, var(--primary) 55%, var(--card))" strokeWidth="2" />
                </pattern>
              </defs>
              {/* Forecast region */}
              {fIndex >= 0 && (
                <g>
                  <rect x={round(left + band * fIndex)} y={top} width={round(right - left - band * fIndex)} height={bottom - top} fill="color-mix(in oklab, var(--foreground) 3.5%, transparent)" />
                  <line x1={round(left + band * fIndex)} x2={round(left + band * fIndex)} y1={top - 6} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" strokeDasharray="3 3" shapeRendering="crispEdges" />
                  <text x={round(left + band * fIndex) + 8} y={top + 14} className="text-[11px] font-medium" fill="var(--muted-foreground)" style={HALO}>
                    {t.forecast}
                  </text>
                </g>
              )}
              {/* Grid and both scales */}
              <g className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
                {nl.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">
                      {lTickTexts[i]}
                    </text>
                  </g>
                ))}
                {hasRight &&
                  rightTicks.map((v, i) => (
                    <text key={v} x={right + 10} y={YR(v)} dy="0.34em" textAnchor="start">
                      {rTickTexts[i]}
                    </text>
                  ))}
                {catLabels.map((s, i) =>
                  i % every === 0 ? (
                    <text key={i} x={cx(i)} y={height - 7} textAnchor="middle" className={cn(active === i && 'fill-foreground')}>
                      {s}
                    </text>
                  ) : null,
                )}
              </g>
              {/* Scale titles with their key */}
              {(leftTitle || rightTitle) && (
                <g className="text-[11px] font-medium" fill="var(--muted-foreground)">
                  {leftTitle && <text x={0} y={12}>{leftTitle}</text>}
                  {rightTitle && hasRight && (
                    <text x={width} y={12} textAnchor="end">
                      {rightTitle}
                    </text>
                  )}
                </g>
              )}
              {/* Active band */}
              {shown !== null && shown < n && <rect x={0} y={top - 6} width={round(Math.max(0, band - 4))} height={round(bottom - top + 6)} 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 }} />}
              {/* Forecast range */}
              {range && fIndex >= 0 && (() => {
                const hi: [number, number][] = [];
                const lo: [number, number][] = [];
                for (let i = Math.max(0, fIndex - 1); i < n; i++) {
                  const h = i === fIndex - 1 ? num(data[i], lines[0]?.key) : num(data[i], range.high);
                  const l = i === fIndex - 1 ? num(data[i], lines[0]?.key) : num(data[i], range.low);
                  if (h !== null && l !== null) {
                    hi.push([cx(i), Y(h)]);
                    lo.push([cx(i), Y(l)]);
                  }
                }
                if (hi.length < 2) return null;
                const d = `${linePath(hi)} L${lo[lo.length - 1][0]} ${lo[lo.length - 1][1]} ${linePath([...lo].reverse()).replace(/^M/, 'L')} Z`;
                return <path d={d} fill="color-mix(in oklab, var(--primary) 16%, transparent)" className={anim ? 'kitch-fade' : undefined} style={delay(700)} opacity={focus !== null && focus < series.length ? 0.3 : 1} />;
              })()}
              {/* Area */}
              {area && (() => {
                const p = pts(area, 0, n - 1);
                if (p.length < 2) return null;
                const base = Y(Math.max(0, nl.min));
                const line = linePath(p);
                return (
                  <g className="kitch-series" opacity={op(area)}>
                    <path d={`${line} L${p[p.length - 1][0]} ${base} L${p[0][0]} ${base} Z`} fill={`color-mix(in oklab, ${colorOf(area)} 18%, transparent)`} className={anim ? 'kitch-clip' : undefined} style={delay(100)} />
                    <path d={line} fill="none" stroke={colorOf(area)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" pathLength={anim ? 1 : undefined} className={anim ? 'kitch-draw' : undefined} style={delay(100)} />
                  </g>
                );
              })()}
              {/* Columns */}
              {bars.map((s, k) => (
                <g key={s.key} className="kitch-series" opacity={op(s)}>
                  {data.map((d, i) => {
                    const v = num(d, s.key);
                    if (v === null) return null;
                    const x = cx(i) - (bars.length > 1 ? thick + GAP / 2 : thick / 2) + k * (thick + GAP);
                    const y0 = Y(Math.max(0, nl.min));
                    const y1 = Y(v);
                    const fc = isForecast(i);
                    const color = variant === 'target' ? (hit(i) ? 'var(--kitch-1)' : 'var(--kitch-3)') : colorOf(s);
                    return (
                      <path
                        key={i}
                        d={barPath(x, Math.min(y0, y1), thick, Math.abs(y0 - y1), 4, v >= 0 ? 'up' : 'down')}
                        fill={fc ? `url(#combo-hatch-${variant})` : color}
                        className={cn('transition-opacity duration-200', anim && 'kitch-grow-y')}
                        opacity={active !== null && active !== i ? 0.6 : 1}
                        style={delay(60 + (i / Math.max(1, n)) * 420)}
                      />
                    );
                  })}
                </g>
              ))}
              {/* Target as steps (per row) or one line */}
              {target && (() => {
                if (target.key) {
                  let d = '';
                  data.forEach((_, i) => {
                    const tv = targetAt(i);
                    if (tv === null) return;
                    const x0 = round(left + band * i + band * 0.12);
                    const x1 = round(left + band * (i + 1) - band * 0.12);
                    d += `M${x0} ${Y(tv)}H${x1}`;
                  });
                  return <path d={d} stroke="var(--foreground)" strokeWidth="2" strokeLinecap="round" fill="none" className={anim ? 'kitch-fade' : undefined} style={delay(500)} />;
                }
                const tv = target.value ?? 0;
                return (
                  <g className={anim ? 'kitch-fade' : undefined} style={delay(500)}>
                    <line x1={left} x2={right} y1={Y(tv)} y2={Y(tv)} stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="5 4" />
                    <text x={right - 4} y={Y(tv) - 7} textAnchor="end" className="text-[11px] font-medium" fill="var(--foreground)" style={HALO}>
                      {`${target.label} ${tl(tv)}`}
                    </text>
                  </g>
                );
              })()}
              {/* Lines, solid up to the forecast, dashed after */}
              {lines.map((s) => {
                const solid = pts(s, 0, cut);
                const dashed = fIndex >= 0 ? pts(s, Math.max(0, fIndex - 1), n - 1) : [];
                const c = colorOf(s);
                return (
                  <g key={s.key} className="kitch-series" opacity={op(s)}>
                    {solid.length > 1 && <path d={linePath(solid)} fill="none" stroke="var(--kitch-surface)" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" />}
                    {solid.length > 1 && <path d={linePath(solid)} fill="none" stroke={c} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '5 4' : undefined} pathLength={anim && !s.dashed ? 1 : undefined} className={anim ? (s.dashed ? 'kitch-clip' : 'kitch-draw') : undefined} style={delay(380)} />}
                    {dashed.length > 1 && <path d={linePath(dashed)} fill="none" stroke={c} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" strokeDasharray="5 4" className={anim ? 'kitch-clip' : undefined} style={delay(980)} />}
                    {solid.length > 0 && fIndex >= 0 && <circle cx={solid[solid.length - 1][0]} cy={solid[solid.length - 1][1]} r="4" fill={c} stroke="var(--kitch-surface)" strokeWidth="2" className={anim ? 'kitch-pop' : undefined} style={delay(980)} />}
                  </g>
                );
              })}
              {/* Active dots on lines and area */}
              {shown !== null &&
                shown < n &&
                [...(area ? [area] : []), ...lines].map((s) => {
                  const v = num(data[shown], s.key);
                  return v === null ? null : <GlideDot key={s.key} x={cx(shown)} y={yScale(s)(v)} open={open} jump={jump} color={colorOf(s)} opacity={op(s)} />;
                })}
            </svg>
            <ChartTooltip open={open} jump={jump} x={shown !== null && shown < n ? cx(shown) : 0} y={top} width={width} offset={Math.min(band / 2, 40) + 6} title={shown !== null && shown < n ? titleAt(shown) : ''} rows={shown !== null && shown < n ? rowsAt(shown) : []} />
          </ChartSurface>
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Frequent questions

How do I install Combo chart?

With aiw add @ai-website/combo-chart. The command puts components/ui/combo-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 Combo chart have?

4 variants, Columns and a rate, Actual against plan, Against a target and Forecast. You pick one with the variant prop, for example variant="area-line".

Can I translate the texts of Combo chart?

Yes. Combo chart carries 9 texts of its own in English and German, such as “Forecast”, “Actual” and “Target”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Combo chart?

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

Often searched

Jump to

Copied to the clipboard