Candlestick Chart

OHLC financial chart for session-by-session price action. Install Candlestick Chart from the VLLNT UI registry with the shadcn CLI.

Report a bug

OHLC financial chart for session-by-session price action. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

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

Installation

Add Candlestick Chart to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

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

Source

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.y}> <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 colorClass = isBullish ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"; return ( <g className={colorClass} key={`${candle.label}-${index.toString()}`}> <line stroke="currentColor" strokeLinecap="round" strokeWidth={2} x1={centerX} x2={centerX} y1={wickTop} y2={wickBottom} /> <rect fill="currentColor" fillOpacity={isBullish ? 0.25 : 0.18} height={bodyHeight} rx={4} stroke="currentColor" 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 = ({ as: Heading = "h3", className, data, height = DEFAULT_HEIGHT, ref: reference, showGrid = true, width = DEFAULT_WIDTH, ...props }: CandlestickChartProps & { ref?: React.Ref<HTMLDivElement> }) => { 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";

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

  • @vllnt/ui@^0.3.0