Candlestick Chart
OHLC candles with solid green up and red down bodies, crosshair tooltips on hover or focus, optional volume, and an accessible data table.
Preview
Daily price range
$164
Use arrow keys, Home, and End to explore candles. Enter activates a candle; Escape dismisses details. Invalid OHLC records are shown as gaps.
Hover or focus a candle to inspect OHLC and volume.
View data
| Period | Open | High | Low | Close | Volume |
|---|---|---|---|---|---|
| Sep 01 | $142 | $149 | $140 | $147 | 12,000 |
| Sep 02 | $147 | $151 | $143 | $145 | 17,000 |
| Sep 03 | $145 | $153 | $144 | $151 | 21,500 |
| Sep 04 | $151 | $155 | $147 | $149 | 14,300 |
| Sep 05 | $149 | $152 | $146 | $149 | 9,800 |
| Sep 08 | $150 | $158 | $148 | $156 | 26,700 |
| Sep 09 | $156 | $161 | $154 | $159 | 19,400 |
| Sep 10 | $159 | $162 | $152 | $154 | 28,500 |
| Sep 11 | $154 | $158 | $151 | $157 | 17,600 |
| Sep 12 | $157 | $166 | $155 | $164 | 31,000 |
OHLC and volume
Input order is preserved. Each high must contain open and close; each low must be below them. Invalid sessions appear as gaps. Negative prices and doji candles are supported; invalid or negative volume is omitted.
Price action
164
Use arrow keys, Home, and End to explore candles. Enter activates a candle; Escape dismisses details. Invalid OHLC records are shown as gaps.
Hover or focus a candle to inspect OHLC and volume.
View data
| Period | Open | High | Low | Close | Volume |
|---|---|---|---|---|---|
| Sep 01 | 142 | 149 | 140 | 147 | 12,000 |
| Sep 02 | 147 | 151 | 143 | 145 | 17,000 |
| Sep 03 | 145 | 153 | 144 | 151 | 21,500 |
| Sep 04 | 151 | 155 | 147 | 149 | 14,300 |
| Sep 05 | 149 | 152 | 146 | 149 | 9,800 |
| Sep 08 | 150 | 158 | 148 | 156 | 26,700 |
| Sep 09 | 156 | 161 | 154 | 159 | 19,400 |
| Sep 10 | 159 | 162 | 152 | 154 | 28,500 |
| Sep 11 | 154 | 158 | 151 | 157 | 17,600 |
| Sep 12 | 157 | 166 | 155 | 164 | 31,000 |
Price action
5
Use arrow keys, Home, and End to explore candles. Enter activates a candle; Escape dismisses details. Invalid OHLC records are shown as gaps.
Hover or focus a candle to inspect OHLC and volume.
View data
| Period | Open | High | Low | Close | Volume |
|---|---|---|---|---|---|
| Mon | -8 | -2 | -12 | -4 | 0 |
| Tue | 0 | 0 | 0 | 0 | — |
| Wed (unavailable) | 2 | — | 1 | 3 | — |
| Thu | 3 | 6 | 2 | 5 | 20 |
Price action
0
Use arrow keys, Home, and End to explore candles. Enter activates a candle; Escape dismisses details. Invalid OHLC records are shown as gaps.
Hover or focus a candle to inspect OHLC and volume.
View data
| Period | Open | High | Low | Close | Volume |
|---|---|---|---|---|---|
| Today | 0 | 0 | 0 | 0 | 0 |
Price action
No valid OHLC data available
Installation
Install with the shadcn CLI. This copies source into your project.
npx shadcn@latest add https://livedocs.xyz/r/candlestick-chart.jsonnpx shadcn@latest add @livedocs/candlestick-chartUsage
import { CandlestickChart } from "@/components/ui/candlestick-chart"
export function Example() {
return <CandlestickChart title="Price action" showVolume data={[
{ label: "Monday", open: 142, high: 149, low: 140, close: 147, volume: 12000 },
{ label: "Tuesday", open: 147, high: 151, low: 143, close: 145, volume: 17000 },
{ label: "Wednesday", open: 145, high: 153, low: 144, close: 151, volume: 21500 },
]} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | CandlestickDatum[] | — | Ordered label, open, high, low, close, and optional volume. Invalid OHLC records remain as gaps; negative prices are allowed. |
| title | string | — | Accessible chart heading |
| config | ChartConfig | — | up and down semantic colors |
| showVolume | boolean | true | Show a separate normalized volume pane when valid nonnegative volume exists |
| formatValue | (value: number) => string | — | OHLC and price axis formatter |
| formatVolume | (value: number) => string | — | Volume details formatter |
| onCandleClick | (datum: CandlestickDatum, index: number) => void | — | Pointer or keyboard activation with original data index |
| isLoading | boolean | — | Shared loading reaction |
| reaction | ChartReactionOptions | — | Per-chart emotion, metric, asset, and resolver overrides |
| emptyLabel | string | — | Message when no valid OHLC records remain |
| className | string | — | Card classes |
Source
"use client";
import * as React from "react";
import { Card } from "@/components/ui/card";
import { ChartContainer, ChartTooltipSurface, type ChartConfig } from "@/components/ui/chart";
import { ChartSkeleton, useChartReducedMotion, useChartReactions, type ChartReactionOptions } from "@/components/ui/chart-reactions";
import { cn } from "@/lib/utils";
export type CandlestickDatum = { label: string; open: number; high: number; low: number; close: number; volume?: number };
export type CandlestickChartProps = {
data: CandlestickDatum[];
title?: string;
config?: ChartConfig;
showVolume?: boolean;
formatValue?: (value: number) => string;
formatVolume?: (value: number) => string;
onCandleClick?: (datum: CandlestickDatum, index: number) => void;
isLoading?: boolean;
reaction?: ChartReactionOptions;
emptyLabel?: string;
className?: string;
};
const defaultConfig = { up: { label: "Up · close ≥ open", color: "var(--chart-up)" }, down: { label: "Down · close < open", color: "var(--destructive)" } } satisfies ChartConfig;
const formatNumber = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 2 });
const validCandle = (row: CandlestickDatum) => [row.open, row.high, row.low, row.close].every(Number.isFinite) && row.low <= Math.min(row.open, row.close) && row.high >= Math.max(row.open, row.close);
const validVolume = (value: number | undefined): value is number => value !== undefined && Number.isFinite(value) && value >= 0;
export function CandlestickChart({ data, title = "Price action", config = defaultConfig, showVolume = true, formatValue = formatNumber, formatVolume = formatNumber, onCandleClick, isLoading, reaction, emptyLabel = "No valid OHLC data available", className }: CandlestickChartProps) {
const id = React.useId();
const plot = React.useRef<SVGSVGElement>(null);
const [cursor, setCursor] = React.useState(0);
const [hovered, setHovered] = React.useState<number | null>(null);
const [focused, setFocused] = React.useState<number | null>(null);
const [dismissed, setDismissed] = React.useState(false);
const active = dismissed ? null : hovered ?? focused;
const tooltipHost = React.useRef<HTMLDivElement>(null);
const [tooltipPoint, setTooltipPoint] = React.useState({ left: 0, top: 0 });
const placeTooltip = (element: SVGGElement) => {
const host = tooltipHost.current?.getBoundingClientRect();
const bounds = element.getBoundingClientRect();
if (host) setTooltipPoint({ left: Math.max(0, Math.min(bounds.left - host.left + bounds.width + 8, host.width - 224)), top: bounds.top - host.top + 8 });
setDismissed(false);
};
React.useEffect(() => {
const dismiss = (event: KeyboardEvent) => { if (event.key === "Escape") setDismissed(true); };
document.addEventListener("keydown", dismiss);
return () => document.removeEventListener("keydown", dismiss);
}, []);
const reducedMotion = useChartReducedMotion();
const settings = useChartReactions();
const animate = !reducedMotion && settings.animationsEnabled !== false;
const rows = data.map((row) => ({ ...row, valid: validCandle(row) }));
const validRows = rows.filter((row) => row.valid);
const magnitude = validRows.reduce((max, row) => Math.max(max, Math.abs(row.high), Math.abs(row.low)), 1);
const lower = validRows.reduce((min, row) => Math.min(min, row.low / magnitude), Infinity);
const upper = validRows.reduce((max, row) => Math.max(max, row.high / magnitude), -Infinity);
const padding = upper === lower ? Math.max(Math.abs(upper) * 0.05, 0.05) : (upper - lower) * 0.08;
const low = lower - padding;
const high = upper + padding;
const y = (value: number) => 24 + (high - value / magnitude) / (high - low) * 192;
const maxVolume = rows.reduce((max, row) => validVolume(row.volume) ? Math.max(max, row.volume) : max, 0) || 1;
const hasVolume = showVolume && rows.some((row) => validVolume(row.volume));
const chartWidth = Math.max(560, rows.length * 42 + 96);
const chartHeight = hasVolume ? 316 : 256;
const step = (chartWidth - 100) / Math.max(rows.length, 1);
const candleWidth = Math.min(20, step * 0.52);
const x = (index: number) => 78 + index * step + step / 2;
const selected = active === null ? undefined : rows[active];
const describe = (row: typeof rows[number]) => row.valid ? `${row.label}: open ${formatValue(row.open)}, high ${formatValue(row.high)}, low ${formatValue(row.low)}, close ${formatValue(row.close)}${validVolume(row.volume) ? `, volume ${formatVolume(row.volume)}` : ""}` : `${row.label}: unavailable OHLC data`;
function navigate(event: React.KeyboardEvent<SVGGElement>, index: number) {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = Math.min(index + 1, rows.length - 1);
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = Math.max(0, index - 1);
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = rows.length - 1;
else if (event.key === "Escape") { setDismissed(true); return; }
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setFocused(index); setDismissed(false); if (rows[index]?.valid) onCandleClick?.(data[index]!, index); return; }
else return;
event.preventDefault();
setCursor(next);
plot.current?.querySelector<SVGGElement>(`[data-candle="${next}"]`)?.focus();
}
return (
<Card className={cn("min-w-0 w-full p-5", className)} role="region" aria-label={title} aria-busy={isLoading || settings.isLoading}>
<ChartSkeleton isLoading={isLoading}>
<h3 className="text-[15px] font-medium tracking-tight">{title}</h3>
{validRows.length ? <p className="mt-1 tabular-nums text-4xl font-medium tracking-tight">{formatValue(validRows[validRows.length - 1]!.close)}</p> : null}
<ChartContainer config={{ ...defaultConfig, ...config }} data={rows} variant="plain" isLoading={isLoading} loadingVariant="candlestick" reaction={reaction} className="mt-5 min-h-64">
{!validRows.length ? <p role="status" className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">{emptyLabel}</p> : (
<>
<div className="mb-3 flex flex-wrap gap-4 text-[11px] text-muted-foreground"><span className="flex items-center gap-1.5"><span className="size-2 rounded-[2px] bg-chart-up" aria-hidden />Up</span><span className="flex items-center gap-1.5"><span className="size-2 rounded-[2px] bg-destructive" aria-hidden />Down</span>{hasVolume ? <span>Volume below</span> : null}</div>
<p id={`${id}-help`} className="sr-only">Use arrow keys, Home, and End to explore candles. Enter activates a candle; Escape dismisses details. Invalid OHLC records are shown as gaps.</p>
<div ref={tooltipHost} className="relative">
<div className="overflow-x-auto p-1" onScroll={() => setDismissed(true)}>
<svg ref={plot} viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="block w-full overflow-visible" style={{ minWidth: chartWidth }} role="group" aria-label={title} aria-describedby={`${id}-help`}>
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
const normalized = high - (high - low) * fraction;
const value = normalized * magnitude;
return <g key={fraction} aria-hidden><line x1={64} x2={chartWidth - 12} y1={24 + fraction * 192} y2={24 + fraction * 192} stroke="var(--border)" strokeDasharray="3 5" /><text x={56} y={28 + fraction * 192} textAnchor="end" className="fill-muted-foreground text-[10px]">{Number.isFinite(value) ? formatValue(value) : "—"}</text></g>;
})}
{hasVolume ? <line x1={64} x2={chartWidth - 12} y1={238} y2={238} stroke="var(--border)" aria-hidden /> : null}
{rows.map((row, index) => {
const up = row.close >= row.open;
const color = `var(--color-${up ? "up" : "down"})`;
const bodyTop = row.valid ? Math.min(y(row.open), y(row.close)) : 120;
const bodyHeight = row.valid ? Math.max(1.5, Math.abs(y(row.open) - y(row.close))) : 0;
return <g key={index}>
<g data-candle={index} tabIndex={index === Math.min(cursor, rows.length - 1) ? 0 : -1} role={onCandleClick ? "button" : "img"} aria-label={describe(row)} aria-describedby={active === index ? `${id}-tooltip` : undefined}
onFocus={(event) => { setFocused(index); setCursor(index); placeTooltip(event.currentTarget); }} onBlur={() => setFocused(null)} onMouseEnter={(event) => { setHovered(index); placeTooltip(event.currentTarget); }} onMouseLeave={() => setHovered(null)} onKeyDown={(event) => navigate(event, index)} onClick={(event) => { event.currentTarget.focus(); placeTooltip(event.currentTarget); if (row.valid) onCandleClick?.(data[index]!, index); }}
opacity={active !== null && active !== index ? 0.6 : 1}
className={cn("cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", animate && "transition-opacity duration-150")}>
<rect x={x(index) - step / 2 + 2} y={18} width={step - 4} height={hasVolume ? 269 : 206} rx={4} fill="var(--accent)" fillOpacity={active === index ? 0.7 : 0} className={cn(animate && "transition-[fill-opacity] duration-150")} />
{row.valid ? <>
<line x1={x(index)} x2={x(index)} y1={y(row.high)} y2={y(row.low)} stroke={color} strokeWidth={1.5} />
<rect x={x(index) - candleWidth / 2} y={bodyTop - (bodyHeight === 1.5 ? 0.75 : 0)} width={candleWidth} height={bodyHeight} rx={2} fill={color} stroke={color} strokeWidth={1} />
</> : <text x={x(index)} y={124} textAnchor="middle" className="fill-muted-foreground text-xs" aria-hidden>—</text>}
{hasVolume && validVolume(row.volume) ? <rect x={x(index) - candleWidth / 2} y={286 - row.volume / maxVolume * 36} width={candleWidth} height={row.volume / maxVolume * 36} rx={2} fill={row.valid ? color : "var(--muted-foreground)"} fillOpacity={0.3} /> : null}
</g>
{index % Math.max(1, Math.ceil(rows.length / 10)) === 0 || index === rows.length - 1 ? <text x={x(index)} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[10px]" aria-hidden>{row.label.length > 10 ? `${row.label.slice(0, 9)}…` : row.label}</text> : null}
</g>;
})}
{selected?.valid && active !== null ? <g pointerEvents="none" aria-hidden>
<line x1={x(active)} x2={x(active)} y1={18} y2={hasVolume ? 286 : 216} stroke="var(--muted-foreground)" strokeDasharray="4 4" strokeOpacity={0.65} />
<line x1={64} x2={chartWidth - 12} y1={y(selected.close)} y2={y(selected.close)} stroke="var(--muted-foreground)" strokeDasharray="4 4" strokeOpacity={0.65} />
<circle cx={x(active)} cy={y(selected.close)} r={3} fill="var(--card)" stroke="var(--foreground)" />
</g> : null}
</svg>
</div>
{selected ? <div className="pointer-events-none absolute z-50 w-56 max-w-full" style={tooltipPoint}>
<ChartTooltipSurface id={`${id}-tooltip`} title={selected.label}>
{selected.valid ? (["open", "high", "low", "close"] as const).map((key) => <div key={key} className="flex items-center justify-between gap-6"><span className="capitalize text-muted-foreground">{key}</span><span className="font-mono font-bold tabular-nums">{formatValue(selected[key])}</span></div>) : <p className="text-muted-foreground">Unavailable OHLC data</p>}
<div className="flex items-center justify-between gap-6"><span className="text-muted-foreground">Volume</span><span className="font-mono font-bold tabular-nums">{validVolume(selected.volume) ? formatVolume(selected.volume) : "Unavailable"}</span></div>
</ChartTooltipSurface>
</div> : null}
</div>
<p id={`${id}-detail`} role="status" className="mt-3 min-h-9 rounded-lg border border-border bg-accent/40 px-3 py-2 text-xs">{selected ? describe(selected) : "Hover or focus a candle to inspect OHLC and volume."}</p>
<details className="mt-3 text-xs text-muted-foreground"><summary className="w-fit cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring">View data</summary><div className="mt-2 overflow-x-auto"><table className="w-full text-left"><caption className="sr-only">{title}</caption><thead><tr>{["Period", "Open", "High", "Low", "Close", "Volume"].map((label) => <th key={label} className="p-2">{label}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={index} className="border-t border-border"><th scope="row" className="p-2 font-normal">{row.label}{!row.valid ? " (unavailable)" : ""}</th>{[row.open, row.high, row.low, row.close].map((value, column) => <td key={column} className="p-2 font-mono">{Number.isFinite(value) ? formatValue(value) : "—"}</td>)}<td className="p-2 font-mono">{validVolume(row.volume) ? formatVolume(row.volume) : "—"}</td></tr>)}</tbody></table></div></details>
</>
)}
</ChartContainer>
</ChartSkeleton>
</Card>
);
}