Candlestick Chart

OHLC financial chart for session-by-session price action.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/candlestick-chart.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

import * as React from "react"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type CandlestickDatum = { close: number; high: number; label: string; low: number; open: number; }; export type CandlestickChartProps = { /** Heading tag for the title. Defaults to `h3`. */ as?: HeadingTag; data: CandlestickDatum[]; height?: number; showGrid?: boolean; width?: number; } & React.HTMLAttributes<HTMLDivElement>; type ChartMetrics = { bodyWidth: number; bottomPadding: number; chartHeight: number; columnWidth: number; maxPrice: number; minPrice: number; range: number; topPadding: number; }; const DEFAULT_WIDTH = 760; const DEFAULT_HEIGHT = 260; function formatValue(value: number) { return value.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2, }); } function buildMetrics( data: CandlestickDatum[], height: number, width: number, ): ChartMetrics { const allValues = data.flatMap((candle) => [ candle.high, candle.low, candle.open, candle.close, ]); const minPrice = Math.min(...allValues); const maxPrice = Math.max(...allValues); const range = maxPrice - minPrice || 1; const topPadding = 20; const bottomPadding = 30; const chartHeight = height - topPadding - bottomPadding; const columnWidth = width / data.length; const bodyWidth = Math.max(columnWidth * 0.56, 8); return { bodyWidth, bottomPadding, chartHeight, columnWidth, maxPrice, minPrice, range, topPadding, }; } function getYForPrice(price: number, metrics: ChartMetrics) { const ratio = (price - metrics.minPrice) / metrics.range; return metrics.topPadding + metrics.chartHeight - ratio * metrics.chartHeight; } function PriceGrid({ metrics, showGrid, width, }: { metrics: ChartMetrics; showGrid: boolean; width: number; }) { if (!showGrid) { return null; } const ticks = Array.from({ length: 4 }, (_, index) => { const ratio = index / 3; return { value: metrics.maxPrice - ratio * metrics.range, y: metrics.topPadding + ratio * metrics.chartHeight, }; }); return ticks.map((tick) => ( <g key={tick.value}> <line stroke="oklch(var(--border))" strokeDasharray="4 6" strokeOpacity="0.8" x1="0" x2={width} y1={tick.y} y2={tick.y} /> <text fill="oklch(var(--muted-foreground))" fontSize="11" textAnchor="end" x={width - 6} y={tick.y - 4} > {formatValue(tick.value)} </text> </g> )); } function CandleMarks({ data, height, metrics, }: { data: CandlestickDatum[]; height: number; metrics: ChartMetrics; }) { return data.map((candle, index) => { const centerX = metrics.columnWidth * index + metrics.columnWidth / 2; const wickTop = getYForPrice(candle.high, metrics); const wickBottom = getYForPrice(candle.low, metrics); const openY = getYForPrice(candle.open, metrics); const closeY = getYForPrice(candle.close, metrics); const bodyY = Math.min(openY, closeY); const bodyHeight = Math.max(Math.abs(openY - closeY), 3); const isBullish = candle.close >= candle.open; const fill = isBullish ? "hsl(142 71% 45%)" : "hsl(348 83% 47%)"; return ( <g key={candle.label}> <line stroke={fill} strokeLinecap="round" strokeWidth={2} x1={centerX} x2={centerX} y1={wickTop} y2={wickBottom} /> <rect fill={fill} fillOpacity={isBullish ? 0.25 : 0.18} height={bodyHeight} rx={4} stroke={fill} strokeWidth={1.5} width={metrics.bodyWidth} x={centerX - metrics.bodyWidth / 2} y={bodyY} > <title> {`${candle.label}: O ${formatValue(candle.open)} H ${formatValue(candle.high)} L ${formatValue(candle.low)} C ${formatValue(candle.close)}`} </title> </rect> <text fill="oklch(var(--muted-foreground))" fontSize="11" textAnchor="middle" x={centerX} y={height - 8} > {candle.label} </text> </g> ); }); } function SessionPill({ sessionChange }: { sessionChange: number }) { const isPositive = sessionChange >= 0; const TrendIcon = isPositive ? ArrowUpRight : ArrowDownRight; return ( <div className={cn( "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium", isPositive ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400", )} > <TrendIcon className="size-4" /> {sessionChange >= 0 ? "+" : ""} {formatValue(sessionChange)} </div> ); } export const CandlestickChart = React.forwardRef< HTMLDivElement, CandlestickChartProps >( ( { as: Heading = "h3", className, data, height = DEFAULT_HEIGHT, showGrid = true, width = DEFAULT_WIDTH, ...props }, reference, ) => { const firstCandle = data[0]; const finalCandle = data.at(-1); if (!firstCandle || !finalCandle) { return null; } const metrics = buildMetrics(data, height, width); const sessionChange = finalCandle.close - firstCandle.open; return ( <div className={cn( "rounded-2xl border border-border bg-card/80 p-4 shadow-sm", className, )} ref={reference} {...props} > <div className="mb-4 flex flex-wrap items-start justify-between gap-3"> <div> <p className="text-xs font-medium uppercase tracking-[0.28em] text-muted-foreground"> OHLC session </p> <Heading className="text-lg font-semibold text-foreground"> Candlestick chart </Heading> </div> <SessionPill sessionChange={sessionChange} /> </div> <svg aria-label="Candlestick chart" className="h-full w-full" height={height} role="img" viewBox={`0 0 ${width} ${height}`} width={width} > <PriceGrid metrics={metrics} showGrid={showGrid} width={width} /> <CandleMarks data={data} height={height} metrics={metrics} /> </svg> </div> ); }, ); CandlestickChart.displayName = "CandlestickChart";

Dependencies

  • @vllnt/ui@^0.2.1