Candlestick chart
Prices as open, high, low and close. As candles, candles with volume below, a range switch from one month to a year, or OHLC bars with a moving average. Rising days hollow, falling days filled.
One of 6 components in KPI, live and map charts
Variants
Details
What Candlestick chart brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/candlestick-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CandlestickChartProps interface from components/ui/candlestick-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.
variantoptionalCandlestickVariantcandleswith-volumerangesohlc
datarequiredCandle[]Oldest first
formatoptionalIntl.NumberFormatOptionsPrice format, e.g. { style: 'currency', currency: 'EUR' }
rangesoptional{ label: string; days: number }[]ranges: the switch, days per option, 0 for everything as weeks. Default 1M, 3M, 1Y.
averageoptionalnumberohlc: days of the moving average. Default 20.
heightoptionalnumberHeight of the price plot in px. Default 300.
Built-in texts
Candlestick chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| open | English Open | German Eröffnung |
| high | English High | German Hoch |
| low | English Low | German Tief |
| close | English Close | German Schluss |
| change | English Change | German Veränderung |
| volume | English Volume | German Umsatz |
| rise | English Closed higher | German Höher geschlossen |
| fall | English Closed lower | German Tiefer geschlossen |
| average | English {n} day average | German Schnitt {n} Tage |
| range | English Period | German Zeitraum |
| date | English Date | German Datum |
| week | English Week of {d} | German Woche ab {d} |
| summary | English From {a} to {b}, low {lo}, high {hi}. | German Von {a} bis {b}, Tief {lo}, Hoch {hi}. |
Accessibility
Read from the source of the component.
- Candlestick chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Page up and Page down itself.
- Sets aria-checked and aria-label, so screen readers announce purpose and state.
- Uses the roles radio and radiogroup.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 356 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, PlotAxes, linear, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Prices over time as open, high, low and close.
* candles a body from open to close and a wick from low to high
* with-volume the candles and the traded volume in a second panel below,
* its own scale, the same time axis (never a second y axis)
* ranges a switch for one month, three months and a year, the year
* folded into weeks so every candle stays readable
* ohlc open and close as ticks on a bar, with a moving average
*
* Rising days are hollow, falling days filled: the shape tells the
* direction, the success and destructive tones only repeat it. A crosshair
* finds the day, the price of the day sits as a tag on the axis.
*/
export type CandlestickVariant = 'candles' | 'with-volume' | 'ranges' | 'ohlc';
export interface Candle {
/** "2026-03-14" */
date: string;
open: number;
high: number;
low: number;
close: number;
volume?: number;
}
export interface CandlestickChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: CandlestickVariant;
/** Oldest first */
data: Candle[];
/** Price format, e.g. { style: 'currency', currency: 'EUR' } */
format?: Intl.NumberFormatOptions;
/** ranges: the switch, days per option, 0 for everything as weeks. Default 1M, 3M, 1Y. */
ranges?: { label: string; days: number }[];
/** ohlc: days of the moving average. Default 20. */
average?: number;
/** Height of the price plot in px. Default 300. */
height?: number;
className?: string;
}
const TEXTS = {
en: { open: 'Open', high: 'High', low: 'Low', close: 'Close', change: 'Change', volume: 'Volume', rise: 'Closed higher', fall: 'Closed lower', average: '{n} day average', range: 'Period', date: 'Date', week: 'Week of {d}', summary: 'From {a} to {b}, low {lo}, high {hi}.' },
de: { open: 'Eröffnung', high: 'Hoch', low: 'Tief', close: 'Schluss', change: 'Veränderung', volume: 'Umsatz', rise: 'Höher geschlossen', fall: 'Tiefer geschlossen', average: 'Schnitt {n} Tage', range: 'Zeitraum', date: 'Datum', week: 'Woche ab {d}', summary: 'Von {a} bis {b}, Tief {lo}, Hoch {hi}.' },
};
const UP = 'var(--kitfg-up)';
const DOWN = 'var(--kitfg-down)';
/** Folds days into weeks (Monday based) */
function weekly(days: Candle[]): Candle[] {
const out: Candle[] = [];
for (const d of days) {
const dt = new Date(`${d.date}T00:00:00Z`);
const monday = new Date(dt);
monday.setUTCDate(dt.getUTCDate() - ((dt.getUTCDay() + 6) % 7));
const key = monday.toISOString().slice(0, 10);
const last = out[out.length - 1];
if (last && last.date === key) {
last.high = Math.max(last.high, d.high);
last.low = Math.min(last.low, d.low);
last.close = d.close;
last.volume = (last.volume ?? 0) + (d.volume ?? 0);
} else out.push({ ...d, date: key });
}
return out;
}
/**
* Open, high, low and close per day: candles, with volume, with a range switch, or as OHLC bars.
* <CandlestickChart variant="with-volume" data={[{ date: '2026-03-02', open: 41.2, high: 42.8, low: 40.9, close: 42.5, volume: 182000 }]} />
*/
export function CandlestickChart(props: CandlestickChartProps) {
const { variant = 'candles', data, format, average = 20, height: heightProp, 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 ?? { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const tick = tickFormatter(intl, format);
const vol = numberFormatter(intl, { notation: 'compact', maximumFractionDigits: 1 });
const pct = numberFormatter(intl, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: 'exceptZero' });
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 ranges = props.ranges ?? (intl === 'de-DE' ? [{ label: '1 M', days: 22 }, { label: '3 M', days: 65 }, { label: '1 J', days: 0 }] : [{ label: '1M', days: 22 }, { label: '3M', days: 65 }, { label: '1Y', days: 0 }]);
const [range, setRange] = useState(1);
const phone = width < 520;
const name = label ?? title ?? '';
// Which candles: ranges slice or fold, a phone shows fewer days of the plain variants
const series: Candle[] = (() => {
if (variant === 'ranges') {
const r = ranges[range] ?? ranges[0];
return r.days ? data.slice(-r.days) : weekly(data);
}
const max = phone ? 44 : 90;
return data.length > max ? data.slice(-max) : data;
})();
const n = series.length;
const showVolume = variant === 'with-volume' && series.some((d) => d.volume !== undefined);
// Moving average over the full data so the first visible days have a value
const offset = data.length - n;
const ma: (number | null)[] =
variant === 'ohlc'
? series.map((_, i) => {
const end = offset + i;
if (end + 1 < average) return null;
let s = 0;
for (let k = end - average + 1; k <= end; k++) s += data[k].close;
return s / average;
})
: [];
const lo = Math.min(...series.map((d) => d.low));
const hi = Math.max(...series.map((d) => d.high));
const nice = niceTicks(lo - (hi - lo) * 0.04, hi + (hi - lo) * 0.04, 5);
const yLabels = nice.ticks.map(tick);
const priceH = heightProp ?? (phone ? 260 : 300);
const volH = showVolume ? 78 : 0;
const height = priceH + volH + (showVolume ? 14 : 0);
const left = Math.max(...yLabels.map((s) => textWidth(s, 11)), 20) + 14;
const right = width - 8;
const top = 12;
const bottom = priceH - 28;
const Y = linear(nice.min, nice.max, bottom, top);
const band = (right - left) / Math.max(1, n);
const cx = (i: number) => round(left + band * (i + 0.5));
const body = Math.max(1.5, Math.min(11, band * 0.62));
const vTop = priceH + 14;
const vBottom = height - 2;
const vMax = Math.max(1, ...series.map((d) => d.volume ?? 0));
// Month labels on the first day of each month that fits
const monthFmt = new Intl.DateTimeFormat(intl, { month: 'short', timeZone: 'UTC' });
const dayFmt = new Intl.DateTimeFormat(intl, { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
const shortDay = new Intl.DateTimeFormat(intl, { day: 'numeric', month: 'short', timeZone: 'UTC' });
const xTicks: { at: number; label: string }[] = [];
let lastX = -Infinity;
series.forEach((d, i) => {
const m = d.date.slice(0, 7);
if (i === 0 || m !== series[i - 1].date.slice(0, 7)) {
const x = cx(i);
const label = monthFmt.format(new Date(`${d.date}T00:00:00Z`));
if (x - lastX > textWidth(label, 11) + 16 && x > left + 10) {
xTicks.push({ at: x, label });
lastX = x;
}
}
});
const weekly_ = variant === 'ranges' && !(ranges[range]?.days);
const dateTitle = (d: Candle) => (weekly_ ? t.week.replace('{d}', shortDay.format(new Date(`${d.date}T00:00:00Z`))) : dayFmt.format(new Date(`${d.date}T00:00:00Z`)));
const rows = (i: number): TooltipRow[] => {
const d = series[i];
const up = d.close >= d.open;
const out: TooltipRow[] = [
{ key: 'c', label: t.close, value: fmt(d.close), color: up ? UP : DOWN, shape: 'rect' },
{ key: 'o', label: t.open, value: fmt(d.open) },
{ key: 'h', label: t.high, value: fmt(d.high) },
{ key: 'l', label: t.low, value: fmt(d.low) },
{ key: 'p', label: t.change, value: pct((d.close - d.open) / d.open) },
];
if (d.volume !== undefined && showVolume) out.push({ key: 'v', label: t.volume, value: vol(d.volume) });
if (variant === 'ohlc' && ma[i] !== null) out.push({ key: 'm', label: t.average.replace('{n}', String(average)), value: fmt(ma[i]!), color: 'var(--primary)', shape: 'line' });
return out;
};
const pick = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const x = (clientX - r.left) * (width / (r.width || width));
setActive(Math.max(0, Math.min(n - 1, Math.floor((x - left) / band))));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? n;
let next: number | null = cur;
if (e.key === 'ArrowRight') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'PageUp') next = Math.max(0, cur - 5);
else if (e.key === 'PageDown') next = Math.min(n - 1, cur + 5);
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const first = series[0];
const last = series[n - 1];
const auto = first && last ? t.summary.replace('{a}', fmt(first.open)).replace('{b}', fmt(last.close)).replace('{lo}', fmt(lo)).replace('{hi}', fmt(hi)) : '';
const live = active !== null && series[active] ? `${dateTitle(series[active])}. ${rows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
const table = <ChartTable caption={name} head={[t.date, t.open, t.high, t.low, t.close, ...(showVolume ? [t.volume] : [])]} rows={series.map((d) => [dateTitle(d), fmt(d.open), fmt(d.high), fmt(d.low), fmt(d.close), ...(showVolume ? [vol(d.volume ?? 0)] : [])])} />;
const delay = (i: number) => (anim ? ({ '--kitch-d': `${round(60 + (i / Math.max(1, n)) * 700, 0)}ms` } as React.CSSProperties) : undefined);
const activeD = shown !== null ? series[shown] : undefined;
// The price tag sits on the axis, tick labels under it step aside
const tagY = activeD ?? series[n - 1] ? Y((activeD ?? series[n - 1]).close) : null;
const actions =
variant === 'ranges' ? (
<div role="radiogroup" aria-label={t.range} className="inline-flex h-9 items-center rounded-md bg-muted p-[3px] shadow-[inset_0_0_0_1px_var(--border)]">
{ranges.map((r, i) => (
<button
key={r.label}
type="button"
role="radio"
aria-checked={range === i}
tabIndex={range === i ? 0 : -1}
onClick={() => {
setRange(i);
setActive(null);
}}
onKeyDown={(e) => {
const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
e.preventDefault();
const k = (range + step + ranges.length) % ranges.length;
setRange(k);
(e.currentTarget.parentElement?.children[k] as HTMLElement | undefined)?.focus();
}}
className={cn("relative h-full min-w-11 rounded-[5px] px-3 text-[13px] font-medium tabular-nums transition-[background-color,color,box-shadow] duration-300 after:absolute after:-inset-y-[7px] after:inset-x-0 after:content-['']", range === i ? 'bg-background text-foreground shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]' : 'text-muted-foreground hover:text-foreground')}
>
{r.label}
</button>
))}
</div>
) : undefined;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={actions} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
<li className="flex items-center gap-1.5">
<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden>
<path d="M5 0V14" stroke={UP} strokeWidth="1.5" />
<rect x="1.25" y="3.25" width="7.5" height="7.5" rx="1.5" fill="var(--card)" stroke={UP} strokeWidth="1.5" />
</svg>
{t.rise}
</li>
<li className="flex items-center gap-1.5">
<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden>
<path d="M5 0V14" stroke={DOWN} strokeWidth="1.5" />
<rect x="1" y="3" width="8" height="8" rx="1.5" fill={DOWN} />
</svg>
{t.fall}
</li>
{variant === 'ohlc' && (
<li className="flex items-center gap-1.5">
<Swatch shape="line" color="var(--primary)" />
{t.average.replace('{n}', String(average))}
</li>
)}
</ul>
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(n - 1)} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg key={variant === 'ranges' ? range : 0} width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className={cn('block overflow-visible', variant === 'ranges' && 'animate-[kitch-fade_.45s_var(--ease-kit)] motion-reduce:animate-none')}>
<PlotAxes left={left} right={right} top={top} bottom={bottom} height={priceH} yTicks={nice.ticks.map((v, i) => ({ at: Y(v), label: tagY !== null && Math.abs(Y(v) - tagY) < 14 ? '' : yLabels[i] }))} xTicks={xTicks} />
{/* Volume panel: own scale, same days */}
{showVolume && (
<g>
<line x1={left} x2={right} y1={vBottom} y2={vBottom} stroke="var(--kitch-axis)" strokeWidth="1" shapeRendering="crispEdges" />
<text x={left - 10} y={vTop + 4} dy="0.34em" textAnchor="end" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
{vol(vMax)}
</text>
<text x={left - 10} y={vBottom} textAnchor="end" className="text-[11px]" fill="var(--muted-foreground)">
{t.volume}
</text>
{series.map((d, i) => {
const h = ((d.volume ?? 0) / vMax) * (vBottom - vTop);
return <rect key={d.date} x={round(cx(i) - body / 2)} y={round(vBottom - h)} width={round(body)} height={round(Math.max(0.5, h))} rx="1" fill={d.close >= d.open ? UP : DOWN} opacity={active === i ? 0.75 : 0.4} className={anim ? 'kitch-grow-y' : undefined} style={delay(i)} />;
})}
</g>
)}
{/* Crosshair */}
{activeD && (
<g className="kitch-glide" style={{ opacity: open ? 1 : 0 }}>
<line x1={cx(shown!)} x2={cx(shown!)} y1={top} y2={showVolume ? vBottom : bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
<line x1={left} x2={right} y1={Y(activeD.close)} y2={Y(activeD.close)} stroke="var(--kitch-axis)" strokeWidth="1" strokeDasharray="3 3" />
</g>
)}
{/* Moving average */}
{variant === 'ohlc' && (
<path
d={ma.map((v, i) => (v === null ? '' : `${i && ma[i - 1] !== null ? 'L' : 'M'}${cx(i)} ${Y(v)}`)).join('')}
pathLength={1}
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinejoin="round"
className={anim ? 'kitch-draw' : undefined}
style={anim ? ({ '--kitch-d': '600ms', '--kitch-t': '1.2s' } as React.CSSProperties) : undefined}
/>
)}
{/* Candles or bars */}
<g>
{series.map((d, i) => {
const up = d.close >= d.open;
const color = up ? UP : DOWN;
const x = cx(i);
const yo = Y(d.open);
const yc = Y(d.close);
const faded = active !== null && active !== i;
if (variant === 'ohlc')
return (
<g key={d.date} className={anim ? 'kitch-fade' : undefined} style={delay(i)} opacity={faded ? 0.6 : 1}>
<line x1={x} x2={x} y1={Y(d.high)} y2={Y(d.low)} stroke={color} strokeWidth="1.5" />
<line x1={round(x - body / 2)} x2={x} y1={yo} y2={yo} stroke={color} strokeWidth="1.5" />
<line x1={x} x2={round(x + body / 2)} y1={yc} y2={yc} stroke={color} strokeWidth="1.5" />
</g>
);
const top_ = Math.min(yo, yc);
const h = Math.max(1, Math.abs(yc - yo));
return (
<g key={d.date} className={anim ? 'kitch-grow-y' : undefined} style={{ ...delay(i), transformOrigin: `${x}px ${round((Y(d.high) + Y(d.low)) / 2)}px`, transformBox: 'view-box' }} opacity={faded ? 0.6 : 1}>
<line x1={x} x2={x} y1={Y(d.high)} y2={Y(d.low)} stroke={color} strokeWidth="1" />
<rect x={round(x - body / 2 + (up ? 0.6 : 0))} y={round(top_ + (up ? 0.6 : 0))} width={round(body - (up ? 1.2 : 0))} height={round(Math.max(1, h - (up ? 1.2 : 0)))} rx={body > 6 ? 1.5 : 0.5} fill={up ? 'var(--kitch-surface)' : color} stroke={up ? color : 'none'} strokeWidth={up ? 1.2 : 0} />
</g>
);
})}
</g>
{/* Price tag of the active or last day on the axis */}
{(() => {
const d = activeD ?? last;
if (!d) return null;
const y = Y(d.close);
const text = fmt(d.close);
const w = textWidth(text, 11) + 12;
const up = d.close >= d.open;
return (
<g className="transition-transform duration-200" style={{ transform: `translateY(${round(y)}px)` }}>
<rect x={left - w - 4} y={-9} width={w} height={18} rx="4" fill="var(--foreground)" />
<rect x={left - 3} y={-4} width={3} height={8} rx="1" fill={up ? UP : DOWN} />
<text x={left - 4 - w / 2} y={0} dy="0.34em" textAnchor="middle" className="text-[11px] font-semibold tabular-nums" fill="var(--background)">
{text}
</text>
</g>
);
})()}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null ? cx(shown) : 0} y={top} width={width} offset={body / 2 + 12} title={activeD ? dateTitle(activeD) : ''} rows={shown !== null && series[shown] ? rows(shown) : []} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Candlestick chart?
With aiw add @ai-website/candlestick-chart. The command puts components/ui/candlestick-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 Candlestick chart have?
4 variants, Candles, With volume, Range switch and OHLC bars. You pick one with the variant prop, for example variant="with-volume".
Can I translate the texts of Candlestick chart?
Yes. Candlestick chart carries 13 texts of its own in English and German, such as “Open”, “High” and “Low”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Candlestick chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Candlestick chart with aiw_components and install it with aiw_add @ai-website/candlestick-chart. Name the variant in your prompt, for example variant="candles", and the assistant writes your content straight into the props.
Can I adjust Candlestick chart without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching CandlestickChart code to copy.