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.
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
Details
What Combo chart brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalComboVariantbar-linearea-linetargetforecast
datarequiredDatum[]Rows, one per period, e.g. { month: '2026-03', bookings: 412, rate: 0.71 }
xKeyrequiredstringKey of the period. "2026-03" and "2026-03-14" are read as dates.
seriesrequiredComboSeries[]Up to two columns, one area and two lines
formatoptionalIntl.NumberFormatOptionsFormat of the left scale, e.g. { style: 'currency', currency: 'EUR' }
rightFormatoptionalIntl.NumberFormatOptionsFormat of the right scale, e.g. { style: 'percent' }
leftTitleoptionalstringTitles of the scales, shown above them with their key
rightTitleoptionalstringtargetoptional{ value?: number; key?: string; label: string }target: a fixed target (value) or one per row (key), drawn on the left scale
forecastFromoptionalstringforecast: 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
xFormatoptionalIntl.DateTimeFormatOptionstooltipXFormatoptionalIntl.DateTimeFormatOptionsheightoptionalnumberPlot height in px, axis labels included. Default 320, 280 on phones.
xLabeloptionalstringHeader 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
| Key | English | German |
|---|---|---|
| hits | English {n} of {total} periods on target | German {n} von {total} Zeiträumen im Ziel |
| forecast | English Forecast | German Prognose |
| actual | English Actual | German Ist |
| target | English Target | German Ziel |
| range | English Range | German Spanne |
| period | English Period | German Zeitraum |
| reached | English reached | German erreicht |
| missed | English below | German darunter |
| between | English {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
'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.