Candlestick chart
Open, high, low and close per period in the up and down tones, with volume under the candles.
For a traded price over sessions, where the swing inside a period is part of the reading. It is the wrong chart for a level read only at its close — a line chart says that with less ink — and for anything that is not a price. Drawn on recharts' ComposedChart: a body between the open and the close, at least a pixel tall, and a 1.5px wick from the high to the low, in --chart-positive when the period closed up, --chart-negative down and --chart-neutral flat. A feed's high below the body, or low above it, is mended out to the body rather than drawn short (candleGeometry, exported). When any period has a volume it stands under its candle along the bottom fifth of the plot, in the first chart slot at 40%, on an axis of its own; showVolume={false} keeps the whole height for the candles. The price axis is fitted to the candles on nice steps, never from zero, and lets the range out so the lowest candle stands clear of the bars. A readout over the plot — the date, O H L C and V — follows the period under the pointer and rests on the last; it is for the eye, because every period is in a visually hidden list beside the image, which candleSummary names. Recharts' keyboard layer is off, so the surface is no tab stop.
Install
npx shadcn@latest add @vibra/candlestick-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatCompact, formatCurrency } from "@/lib/format"
import { CandlestickChart } from "@/components/ui/candlestick-chart"
import { ChartCard } from "@/components/ui/chart-card"
const SESSIONS = [
{ date: "2026-08-17", open: 44.2, high: 44.64, low: 42.54, close: 43.47, volume: 1386000 },
{ date: "2026-08-18", open: 43.47, high: 43.84, low: 43.2, close: 43.51, volume: 1411000 },
{ date: "2026-08-19", open: 43.51, high: 44.31, low: 42.59, close: 42.87, volume: 1347000 },
{ date: "2026-08-20", open: 42.87, high: 44.14, low: 42.2, close: 43.28, volume: 976000 },
{ date: "2026-08-21", open: 43.28, high: 44.64, low: 42.46, close: 44.55, volume: 1878000 },
{ date: "2026-08-24", open: 44.55, high: 44.91, low: 44.3, close: 44.68, volume: 587000 },
{ date: "2026-08-25", open: 44.68, high: 44.72, low: 44.06, close: 44.72, volume: 1117000 },
{ date: "2026-08-26", open: 44.72, high: 46.2, low: 44.46, close: 45.51, volume: 1347000 },
{ date: "2026-08-27", open: 45.51, high: 46.32, low: 45.37, close: 45.84, volume: 1598000 },
{ date: "2026-08-28", open: 45.84, high: 46.34, low: 45.77, close: 45.95, volume: 1344000 },
{ date: "2026-08-31", open: 45.95, high: 45.96, low: 45.57, close: 45.68, volume: 1250000 },
{ date: "2026-09-01", open: 45.68, high: 46.25, low: 44.23, close: 45.63, volume: 1557000 },
{ date: "2026-09-02", open: 45.63, high: 45.97, low: 45.41, close: 45.48, volume: 1362000 },
{ date: "2026-09-03", open: 45.48, high: 46.42, low: 44.88, close: 46.21, volume: 1031000 },
{ date: "2026-09-04", open: 46.21, high: 46.72, low: 46.16, close: 46.66, volume: 1237000 },
{ date: "2026-09-07", open: 46.66, high: 47.91, low: 46.18, close: 47.06, volume: 1116000 },
{ date: "2026-09-08", open: 47.06, high: 47.78, low: 46.92, close: 47.24, volume: 1197000 },
{ date: "2026-09-09", open: 47.24, high: 48.76, low: 46.82, close: 48.5, volume: 1829000 },
{ date: "2026-09-10", open: 48.5, high: 49.29, low: 48.25, close: 48.8, volume: 1252000 },
{ date: "2026-09-11", open: 48.8, high: 50.1, low: 48.59, close: 49.54, volume: 2092000 },
{ date: "2026-09-14", open: 49.54, high: 49.62, low: 49.3, close: 49.51, volume: 1556000 },
{ date: "2026-09-15", open: 49.51, high: 50.13, low: 49.51, close: 49.78, volume: 1403000 },
{ date: "2026-09-16", open: 49.78, high: 50.69, low: 49.56, close: 50.65, volume: 866000 },
{ date: "2026-09-17", open: 50.65, high: 51.06, low: 48.9, close: 49.43, volume: 1252000 },
]
// One date in one format, pinned to UTC, reads the same on the server and in the browser.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export default function CandlestickChartDemo() {
return (
<ChartCard title="Northwind shares" description="Daily, the last 24 sessions" className="w-full">
<CandlestickChart
data={SESSIONS}
label="Northwind shares"
height={240}
valueFormatter={(value) => formatCurrency(value, "USD")}
volumeFormatter={(value) => formatCompact(value)}
indexFormatter={(date) => DAY.format(new Date(`${date}T00:00:00Z`))}
/>
</ChartCard>
)
}Without volume
import { formatCurrency } from "@/lib/format"
import { CandlestickChart } from "@/components/ui/candlestick-chart"
import { ChartCard } from "@/components/ui/chart-card"
// A quoted price with no trades behind it carries no volume, so the plot
// keeps its whole height for the candles.
const WEEKS = [
{ date: "2026-06-29", open: 2.14, high: 2.17, low: 2.1, close: 2.11 },
{ date: "2026-07-06", open: 2.11, high: 2.17, low: 2.06, close: 2.07 },
{ date: "2026-07-13", open: 2.07, high: 2.19, low: 2.01, close: 2.16 },
{ date: "2026-07-20", open: 2.16, high: 2.19, low: 2.15, close: 2.17 },
{ date: "2026-07-27", open: 2.17, high: 2.22, low: 1.98, close: 2.01 },
{ date: "2026-08-03", open: 2.01, high: 2.14, low: 1.91, close: 2.04 },
{ date: "2026-08-10", open: 2.04, high: 2.07, low: 1.93, close: 2.04 },
{ date: "2026-08-17", open: 2.04, high: 2.07, low: 1.99, close: 2.03 },
{ date: "2026-08-24", open: 2.03, high: 2.07, low: 1.99, close: 2.05 },
{ date: "2026-08-31", open: 2.05, high: 2.22, low: 1.98, close: 2.19 },
{ date: "2026-09-07", open: 2.19, high: 2.23, low: 2.1, close: 2.12 },
{ date: "2026-09-14", open: 2.12, high: 2.16, low: 2.09, close: 2.1 },
]
const WEEK_OF = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export default function CandlestickChartNoVolume() {
return (
<ChartCard title="GPU spot price" description="Per hour, weekly, the last 12 weeks" className="w-full">
<CandlestickChart
data={WEEKS}
label="GPU spot price"
height={220}
valueFormatter={(value) => formatCurrency(value, "USD")}
indexFormatter={(date) => WEEK_OF.format(new Date(`${date}T00:00:00Z`))}
/>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | { date: string; open: number; high: number; low: number; close: number; volume?: number }[] | — | One entry per period, in order. A period without a volume draws no bar. |
| valueFormatter | (n: number) => string | thousands-separated number | Formats every price: the axis, the readout, the summary and the rows. |
| volumeFormatter | (n: number) => string | whole number with separators | Formats each period's volume in the readout and the rows. |
| indexFormatter | (date: string) => string | the date as given | Formats each period's date on the axis, in the readout and in the summary, e.g. an ISO date into "Sep 4". |
| showVolume | boolean | true | Draws each period's volume along the bottom fifth of the plot, when any period has one. |
| height | number | 280 | Plot height in pixels, axis band included. |
| label | string | — | Names the chart ahead of its summary, e.g. "Northwind shares", and names its hidden rows. |
Dependencies
Source
"use client"
import * as React from "react"
import {
Bar,
type BarShapeProps,
CartesianGrid,
ComposedChart,
useActiveTooltipDataPoints,
useYAxisScale,
XAxis,
YAxis,
} from "recharts"
import { cn } from "@/lib/utils"
import { ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import {
AXIS_TICK,
axisProps,
chartDefaults,
defaultValueFormatter,
GRID_PROPS,
} from "@/components/ui/chart-core"
import { ChartPlot, ChartRows } from "@/components/ui/chart-frame"
import {
anyVolume,
type Candle,
type CandleDirection,
candleGeometry,
candleRows,
candleSummary,
defaultVolumeFormatter,
priceScale,
READOUT,
sameDate,
} from "./candles"
export { candleGeometry, candleSummary, type Candle } from "./candles"
const VOLUME_AXIS = "volume"
// The tallest volume bar reaches a fifth of the way up the plot.
const VOLUME_SHARE = 5
const VOLUME_OPACITY = 0.4
const WICK_WIDTH = 1.5
// A candle is a mark, not a column: narrower than a bar chart's, with room
// between neighbours so a run of them reads as a series of periods.
const MAX_CANDLE_WIDTH = 14
const CANDLE_GAP = "20%"
const PAINT: Record<CandleDirection, string> = {
up: "var(--chart-positive)",
down: "var(--chart-negative)",
flat: "var(--chart-neutral)",
}
// Nothing for the config to paint: every mark takes its tone from the way its period moved.
const CONFIG: ChartConfig = {}
/** A period as recharts plots it: its prices, and the span its wick covers as one ranged bar. */
type Plotted = Candle & { range: [number, number] }
type BarShape = Pick<BarShapeProps, "x" | "y" | "width" | "height"> & { payload?: Plotted }
/** One candle: the wick, then the body over it, both in the tone of the way the period moved. */
function CandleMark({ x, width, payload }: Omit<BarShape, "y" | "height">) {
const scale = useYAxisScale()
if (!payload || !scale) return null
const geometry = candleGeometry(payload, (value) => scale(value) ?? 0)
const paint = PAINT[geometry.direction]
const middle = x + width / 2
return (
<g data-slot="candle" data-direction={geometry.direction}>
<line
x1={middle}
x2={middle}
y1={geometry.wickTop}
y2={geometry.wickBottom}
stroke={paint}
strokeWidth={WICK_WIDTH}
strokeLinecap="round"
/>
<rect
x={x}
y={geometry.bodyTop}
width={width}
height={geometry.bodyBottom - geometry.bodyTop}
rx={1}
fill={paint}
/>
</g>
)
}
/** One volume bar, drawn only for a period that recorded one. */
function VolumeMark({ x, y, width, height, payload }: BarShape) {
if (typeof payload?.volume !== "number" || !Number.isFinite(payload.volume)) return null
return (
<rect
data-slot="candle-volume"
x={x}
y={y}
width={width}
height={Math.max(height, 0)}
rx={1}
fill="var(--chart-1)"
fillOpacity={VOLUME_OPACITY}
/>
)
}
/**
* Hands the period under the pointer up to the readout. It follows recharts'
* own tooltip state rather than raw pointer events, so whatever opens the
* tooltip — the pointer, a touch, a pinned default — moves the readout too.
*/
function ActivePeriod({ onChange }: { onChange: (date: string | null) => void }) {
const points = useActiveTooltipDataPoints<Plotted>()
const date = points?.[0]?.date ?? null
React.useEffect(() => onChange(date), [date, onChange])
return null
}
export type CandlestickChartProps = Omit<React.ComponentProps<"div">, "children"> & {
data: Candle[]
/** Formats every price: the axis, the readout, the summary and the rows. */
valueFormatter?: (n: number) => string
/** Formats each period's volume in the readout and the rows. */
volumeFormatter?: (n: number) => string
/** Formats each period's date, e.g. an ISO date into "Sep 4". */
indexFormatter?: (date: string) => string
/** Draws each period's volume as a bar along the bottom fifth of the plot, when any period has one. */
showVolume?: boolean
/** Plot height in pixels, axis band included. */
height?: number
/** Names the chart ahead of its summary, e.g. "Northwind shares". */
label?: string
}
function CandlestickChart({
className,
data,
valueFormatter = defaultValueFormatter,
volumeFormatter = defaultVolumeFormatter,
indexFormatter = sameDate,
showVolume = true,
height = chartDefaults.height,
label,
...props
}: CandlestickChartProps) {
const volume = showVolume && anyVolume(data)
const plotted = React.useMemo<Plotted[]>(
() =>
data.map((candle) => ({
...candle,
range: [
Math.min(candle.low, candle.open, candle.close),
Math.max(candle.high, candle.open, candle.close),
],
})),
[data]
)
const scale = priceScale(data, { volume })
// A run of zero volumes still needs a scale, or its bars land at NaN.
const tallest = volume ? Math.max(1, ...data.map((candle) => (Number.isFinite(candle.volume) ? (candle.volume as number) : 0))) : 1
const [active, setActive] = React.useState<string | null>(null)
const shown = data.find((candle) => candle.date === active) ?? data[data.length - 1]
const summary = data.length === 0 ? "Candlestick chart with no data." : `Candlestick chart of ${candleSummary(data, valueFormatter, indexFormatter)}`
return (
<div data-slot="candlestick-chart" data-volume={volume || undefined} className={cn("flex w-full flex-col gap-2", className)} {...props}>
{shown ? (
// The same numbers are in the rows beside the plot, so the readout is
// for the eye only and is not read out a second time.
<p
data-slot="candlestick-chart-readout"
aria-hidden="true"
className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums"
>
<span>{indexFormatter(shown.date)}</span>
{/* Each letter and its price is one unit, so a right-to-left line
reorders the pairs and never splits one. */}
{READOUT.map(([letter, key], i) => (
<span key={letter} className="whitespace-nowrap">
{letter} <span className="font-medium text-foreground">{valueFormatter(shown[key])}</span>
{i < READOUT.length - 1 ? " " : null}
</span>
))}
{volume && typeof shown.volume === "number" ? (
<span className="whitespace-nowrap">
V <span className="font-medium text-foreground">{volumeFormatter(shown.volume)}</span>
</span>
) : null}
</p>
) : null}
<ChartPlot
height={height}
config={CONFIG}
role="img"
aria-label={label ? `${label}. ${summary}` : summary}
className="aspect-auto w-full"
>
{(frame) => (
<ComposedChart
// Named once, by the plot's role="img" and its summary; recharts'
// keyboard layer would add an unnamed role="application" tab stop inside it.
accessibilityLayer={false}
data={plotted}
barCategoryGap={CANDLE_GAP}
margin={{ top: 8, right: 12, bottom: 0, left: 12 }}
>
<CartesianGrid vertical={false} {...GRID_PROPS} />
<XAxis tick={AXIS_TICK} dataKey="date" {...axisProps} minTickGap={16} tickFormatter={(date) => indexFormatter(String(date))} />
<YAxis
tick={AXIS_TICK}
hide={!frame.yAxis(true)}
{...axisProps}
width="auto"
domain={scale.domain}
ticks={scale.ticks}
tickFormatter={(value) => valueFormatter(Number(value))}
/>
{volume ? (
<>
{/* Their own axes, so a volume bar stands under its candle
rather than beside it, and is scaled into the bottom fifth. */}
<XAxis xAxisId={VOLUME_AXIS} dataKey="date" hide />
<YAxis yAxisId={VOLUME_AXIS} hide domain={[0, tallest * VOLUME_SHARE]} />
<Bar
xAxisId={VOLUME_AXIS}
yAxisId={VOLUME_AXIS}
dataKey="volume"
maxBarSize={MAX_CANDLE_WIDTH}
shape={(bar: BarShapeProps) => <VolumeMark x={bar.x} y={bar.y} width={bar.width} height={bar.height} payload={bar.payload} />}
isAnimationActive={false}
/>
</>
) : null}
{/* A crosshair down the period under the pointer; the readout above is its tooltip. */}
<ChartTooltip content={() => null} />
<Bar
dataKey="range"
maxBarSize={MAX_CANDLE_WIDTH}
shape={(bar: BarShapeProps) => <CandleMark x={bar.x} width={bar.width} payload={bar.payload} />}
isAnimationActive={false}
/>
<ActivePeriod onChange={setActive} />
</ComposedChart>
)}
</ChartPlot>
<ChartRows
slot="candlestick-chart-data"
label={`${label ?? "Candlestick chart"} data`}
rows={candleRows(data, { valueFormatter, indexFormatter, volumeFormatter })}
/>
</div>
)
}
export { CandlestickChart }import { defaultValueFormatter } from "@/components/ui/chart-core"
import { niceDomain } from "@/components/ui/chart-scale"
import { formatNumber } from "@/lib/format"
/** One period: what it opened and closed at, how far it ranged, and how much changed hands. */
export type Candle = { date: string; open: number; high: number; low: number; close: number; volume?: number }
export type CandleDirection = "up" | "down" | "flat"
/**
* Where a candle is drawn, given the scale that turns a price into a y: the
* body between the open and the close, the wick from the high to the low.
*
* A body never shrinks below a pixel, so a period that closed where it opened
* still shows as a line. A wick always reaches the body: a high recorded
* below the body, or a low above it, is a feed error, and a body floating off
* its own wick would draw a price the period never had — so the wick is
* mended out to the body rather than drawn short of it.
*/
export function candleGeometry(
candle: Candle,
y: (value: number) => number
): { direction: CandleDirection; bodyTop: number; bodyBottom: number; wickTop: number; wickBottom: number } {
const open = y(candle.open)
const close = y(candle.close)
const bodyTop = Math.min(open, close)
const ends = [y(candle.high), y(candle.low), open, close]
return {
direction: candle.close > candle.open ? "up" : candle.close < candle.open ? "down" : "flat",
bodyTop,
bodyBottom: Math.max(open, close, bodyTop + 1),
wickTop: Math.min(...ends),
wickBottom: Math.max(...ends),
}
}
/** The highest and lowest price a run of periods reached, the same mended way the wicks are drawn. */
function priceRange(data: Candle[]): { high: number; low: number } {
let high = Number.NEGATIVE_INFINITY
let low = Number.POSITIVE_INFINITY
for (const candle of data) {
high = Math.max(high, candle.high, candle.open, candle.close)
low = Math.min(low, candle.low, candle.open, candle.close)
}
return { high, low }
}
/** The date as given: the index formatter a chart uses when it is handed none. */
export const sameDate = (date: string) => date
/** The run of periods in words: how long, from when to when, where it opened and closed, how high and low it went. */
export function candleSummary(
data: Candle[],
valueFormatter: (n: number) => string = defaultValueFormatter,
indexFormatter: (date: string) => string = sameDate
): string {
if (data.length === 0) return "No periods."
const first = data[0]
const last = data[data.length - 1]
const { high, low } = priceRange(data)
const span =
data.length === 1
? `1 period on ${indexFormatter(first.date)}`
: `${data.length} periods from ${indexFormatter(first.date)} to ${indexFormatter(last.date)}`
return `${span}: opened at ${valueFormatter(first.open)}, closed at ${valueFormatter(last.close)}, high ${valueFormatter(high)}, low ${valueFormatter(low)}.`
}
/** The four prices the readout prints, by the letter a trader reads them by: "O 110 H 130 L 105 C 125". */
export const READOUT = [
["O", "open"],
["H", "high"],
["L", "low"],
["C", "close"],
] as const
const hasVolume = (candle: Candle): candle is Candle & { volume: number } =>
typeof candle.volume === "number" && Number.isFinite(candle.volume)
export const defaultVolumeFormatter = (n: number) => formatNumber(n, { maximumFractionDigits: 0 })
/** The data twin: a row per period with its four prices, and its volume when it has one. */
export function candleRows(
data: Candle[],
formatters: {
valueFormatter?: (n: number) => string
indexFormatter?: (date: string) => string
volumeFormatter?: (n: number) => string
} = {}
): { label: string; readings: string }[] {
const { valueFormatter = defaultValueFormatter, indexFormatter = sameDate, volumeFormatter = defaultVolumeFormatter } = formatters
return data.map((candle) => ({
label: indexFormatter(candle.date),
readings: [
`open ${valueFormatter(candle.open)}`,
`high ${valueFormatter(candle.high)}`,
`low ${valueFormatter(candle.low)}`,
`close ${valueFormatter(candle.close)}`,
...(hasVolume(candle) ? [`volume ${volumeFormatter(candle.volume)}`] : []),
].join(", "),
}))
}
/** Whether any period carries a volume to draw. */
export function anyVolume(data: Candle[]): boolean {
return data.some(hasVolume)
}
/**
* The price axis: fitted to the range the candles cover, on nice steps, never
* from zero — a price is read for its movement, which a zero floor flattens.
* With volume drawn, the range is let out downwards by a quarter, so the
* lowest candle stands clear of the bars along the bottom fifth of the plot.
*/
export function priceScale(data: Candle[], options: { volume: boolean }): { domain: [number, number]; ticks: number[] } {
if (data.length === 0) return niceDomain(0, 0)
const { high, low } = priceRange(data)
const room = options.volume ? (high - low) / 4 : 0
return niceDomain(low - room, high, 4, { baseline: "auto" })
}import * as React from "react"
import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>
/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
key: string
label: string
/** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
color?: ChartToken | string
/**
* The key holding the same measure over the period before this one. Drawn as
* a dashed ghost behind the series and printed in its tooltip row as a delta.
*/
compareKey?: string
/**
* The colour the ghost is drawn in: a chart token or any CSS colour. Left
* out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
* the period before out of the palette, grey under every preset — including
* one whose first slot follows a coloured accent.
*/
compareColor?: ChartToken | string
}
/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
data: ChartDatum[]
/** The key holding each point's category — the month, the day, the source. */
index: string
series: ChartSeries[]
/** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
valueFormatter?: (n: number) => string
/** Formats every index label, e.g. an ISO date into "Sep 4". */
indexFormatter?: (v: string | number) => string
/** Plot height in pixels, axis band included. */
height?: number
showLegend?: boolean
showGrid?: boolean
showXAxis?: boolean
showYAxis?: boolean
showTooltip?: boolean
/** Where the series are named: beside their last point, under the plot, or nowhere. */
legend?: ChartLegendPlacement
/** Goal lines, event markers and bands drawn over the plot and read out as text. */
annotations?: ChartAnnotation[]
/** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
compare?: boolean
className?: string
}
// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8
/**
* A series' colour: a chart token becomes its CSS variable, any other string is
* taken as a raw CSS colour, and a series that names none takes the next slot in
* the palette, wrapping past the eighth.
*/
export function seriesColor(series: ChartSeries, i: number): string {
const color = series.color
if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
return isChartToken(color) ? `var(--${color})` : color
}
/**
* The ChartConfig the chart primitive needs — it turns each entry into a
* `--color-<key>` custom property that recharts marks reference.
*/
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
// fromEntries defines own properties, so a series keyed "__proto__" lands as
// data instead of reassigning the config object's prototype.
return Object.fromEntries(
series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
)
}
/**
* The shared starting point: a 280px plot with both axes, a grid, direct labels
* at the end of each series, and a tooltip. The value axis is on — a chart
* without a printed scale is a shape, not a measurement — and the charts turn it
* off themselves when the plot is too small to carry one (`fitsYAxis`).
*/
export const chartDefaults = {
height: 280,
showLegend: true,
showGrid: true,
showXAxis: true,
showYAxis: true,
showTooltip: true,
legend: "inline-end",
} as const
/**
* A drawn line: 2px with round caps and joins reads as a stroke at any size
* and holds its own on a white sheet, where 1.5px thinned to a hair on a
* high-density screen.
*/
export const STROKE_WIDTH = 2
/**
* The gridline: the grid token, dashed, so a rule under the data reads as a
* guide rather than as a border. Every cartesian chart spreads this onto its
* CartesianGrid.
*/
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const
/**
* The dot under the pointer: 3.5px of the series' own colour, ringed in the
* surface it sits on so it stays legible where two lines cross.
* --chart-surface falls back to the card a chart normally lives on; set it on
* any ancestor when the chart sits on another plane.
*/
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const
/**
* The corner a column turns, and the gap between two segments of one stack. A
* stack is rounded as one shape — the top corners on the topmost segment, the
* bottom corners on the lowest — so a bar reads as a printed block rather than
* a pile of separately rounded tiles.
*/
export const BAR_RADIUS = 6
export const BAR_GAP = 1
/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const
/**
* The paint every axis label takes: --chart-axis, the token §3.8 names for the
* printed scale. recharts writes fill="#666" onto its own
* tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
* not reach it — recharts 3 nests labels under
* `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
* at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
* itself, where nothing has to match a selector.
*/
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const
/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
return formatNumber(value)
}
/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
return String(value)
}
/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
data: ChartDatum[]
index: string
series: ChartSeries[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
const raw = datum[index]
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
function readValue(raw: ChartDatum[string], format: (n: number) => string) {
return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}
function joinLabels(labels: string[]): string {
if (labels.length < 2) return labels.join("")
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
/**
* The plotted data as text, one row per point. Every chart renders this into a
* visually hidden list, so the numbers are readable without pointing at a tooltip.
*/
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
const {
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
return data.map((datum) => ({
label: readIndex(datum, index, indexFormatter),
readings: series
.map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
.join(", "),
}))
}
/**
* The heading for a tooltip: the point's own index value, read straight off the
* datum. The primitive resolves its label through the config, which only works
* when the index is a string — reading the datum keeps numeric indexes intact.
*/
export function tooltipIndexLabel(
payload: ReadonlyArray<{ payload?: unknown }> | undefined,
index: string,
format: (v: string | number) => string = defaultIndexFormatter
): string {
const datum = payload?.[0]?.payload
const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
const { data, index, series, indexFormatter = defaultIndexFormatter } = options
if (series.length === 0) return `${kind} with no series.`
const labels = joinLabels(series.map((entry) => entry.label))
if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`
const first = readIndex(data[0], index, indexFormatter)
const last = readIndex(data[data.length - 1], index, indexFormatter)
const count = `${data.length} point${data.length === 1 ? "" : "s"}`
return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"
/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"
export type ChartAnnotation =
/** A threshold across the plot: a goal, a limit, an included allowance. */
| { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
/** A moment on the index axis: a launch, a deploy, an incident. */
| { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
/**
* A stretch of the index axis: a freeze, an outage, a campaign. The label
* reads from the band's start; `align: "end"` hangs it from the band's end
* instead, for a band that runs to the edge of the plot — a 59px word over
* a 30px band at the right edge otherwise runs out of the plot.
*/
| {
kind: "band"
from: number | string
to: number | string
label: string
tone?: ChartAnnotationTone
align?: "start" | "end"
}
const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
neutral: "var(--faint-foreground)",
brand: "var(--brand)",
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
warning: "var(--warning)",
info: "var(--info)",
}
/**
* The paint a *meaning* takes, as opposed to a category.
*
* A series that is coded by status — 200/429/500, up/down, paid/overdue — is
* not one of eight interchangeable hues: it has to resolve to the semantic
* tokens, or a reader learns the wrong colour for "failed" on one page and
* carries it to the next. Categories keep the palette; meanings come from here.
*/
export const CHART_TONES = {
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
neutral: "var(--chart-neutral)",
warning: "var(--warning)",
info: "var(--info)",
} as const
export type ChartTone = keyof typeof CHART_TONES
export function chartTone(tone: ChartTone): string {
return CHART_TONES[tone]
}
/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}
/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
return [annotation.value]
return []
}
/**
* Every annotation as a line of text, appended to a chart's visually hidden
* rows: a goal line a sighted reader can see has to be readable too.
*/
export function annotationRows(
annotations: ChartAnnotation[] = [],
options: {
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
} = {}
): string[] {
const {
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
const at = (value: number | string) =>
typeof value === "number" ? valueFormatter(value) : indexFormatter(value)
return annotations.map((annotation) => {
if (annotation.kind === "line")
return `${annotation.label}: ${
annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
}`
if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
})
}import {
annotationValues,
type ChartAnnotation,
type ChartDatum,
type ChartSeries,
} from "@/components/ui/chart-core"
// A printed scale lands on steps a reader can add up in their head. Anything
// else — recharts' own 0/650/1.3K/2K/2.6K — makes the reader do arithmetic to
// place a point between two gridlines.
const NICE_STEPS = [1, 2, 2.5, 5, 10] as const
/** Rounds a rough interval up to the next 1, 2, 2.5 or 5 × 10ⁿ. */
export function niceStep(rough: number): number {
if (!Number.isFinite(rough) || rough <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(rough))
const scaled = rough / magnitude
const step = NICE_STEPS.find((candidate) => scaled <= candidate * (1 + 1e-9)) ?? 10
return step * magnitude
}
// Summing a float step accumulates error, so each tick is computed from the
// index and then snapped back to the precision the step itself carries.
const snap = (value: number): number => Number(value.toPrecision(12))
/**
* Where a value axis starts. `zero` — the default everywhere — always takes
* zero in, because a count, a sum or a share is read against nothing, and a
* bar that does not start there lies about its length. `auto` fits the axis
* to the data's own range, on the same nice steps, for a level whose
* movement is the reading — a rate, a price, a temperature — and which a
* zero floor would flatten into a line along the top of the plot.
*/
export type ChartBaseline = "zero" | "auto"
/** Ticks from `bottom` to `top` on `step`, both ends included. */
function ticksBetween(bottom: number, top: number, step: number): number[] {
const ticks: number[] = []
for (let i = 0; bottom + i * step <= top + step / 2; i += 1) ticks.push(snap(bottom + i * step))
return ticks
}
/** The ticks and the domain for a range, on nice steps, both ends included. */
export function niceDomain(
min: number,
max: number,
count = 4,
options: { baseline?: ChartBaseline } = {}
): { domain: [number, number]; ticks: number[] } {
if (options.baseline === "auto") return fittedDomain(min, max, count)
const low = Math.min(0, Number.isFinite(min) ? min : 0)
const high = Math.max(Number.isFinite(max) ? max : 0, low)
// A flat series at zero still needs two gridlines, or the plot has no scale.
if (high === low) return { domain: [low, low + 1], ticks: [low, low + 1] }
const step = niceStep((high - low) / Math.max(1, count))
const bottom = Math.floor(low / step + 1e-9) * step
const top = Math.ceil(high / step - 1e-9) * step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/**
* The `auto` baseline: the data's own range, widened out to the nice steps
* either side of it, so 1.05–1.11 prints 1.04 / 1.06 / … / 1.12 rather than
* 0 / 0.5 / 1 / 1.5. A flat level still gets a scale — a step either side of
* it — and nothing to plot falls back to the zero baseline's 0 / 1.
*
* A peak drawn on the frame's edge reads as clipped, so an extreme within a
* quarter of a step of the edge gets one step more: 1.05–1.12 prints up to
* 1.14. That air never takes the axis across zero, which a level that does
* not cross it has no business reaching past.
*/
function fittedDomain(min: number, max: number, count: number): { domain: [number, number]; ticks: number[] } {
const lowest = Number.isFinite(min) ? min : 0
const highest = Number.isFinite(max) ? Math.max(max, lowest) : lowest
let low = lowest
let high = highest
if (high === low) {
if (low === 0) return niceDomain(0, 0, count)
const pad = niceStep(Math.abs(low) / 10)
low -= pad
high += pad
}
const step = niceStep((high - low) / Math.max(1, count))
let bottom = Math.floor(low / step + 1e-9) * step
let top = Math.ceil(high / step - 1e-9) * step
if (lowest - bottom < step / 4) bottom = lowest >= 0 ? Math.max(0, bottom - step) : bottom - step
if (top - highest < step / 4) top = highest <= 0 ? Math.min(0, top + step) : top + step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/** The printed scale from 0 to `max`: `niceTicks(2_900, 4)` is 0, 1000, 2000, 3000. */
export function niceTicks(max: number, count = 4): number[] {
return niceDomain(0, max, count).ticks
}
/**
* The lowest and highest number a set of series reaches, stacked or laid over
* one another. On the zero baseline the extent always takes zero in, so a
* hole in the data or a series of nothing reads as 0 / 0; on `auto` it is the
* data's own range, and ±Infinity when there is nothing to read.
*/
export function chartExtent(
data: ChartDatum[],
series: ChartSeries[],
options: { stacked?: boolean; compare?: boolean; baseline?: ChartBaseline } = {}
): { min: number; max: number } {
const keys = series.flatMap((entry) =>
options.compare && entry.compareKey ? [entry.key, entry.compareKey] : [entry.key]
)
const fitted = options.baseline === "auto"
let min = fitted ? Infinity : 0
let max = fitted ? -Infinity : 0
for (const datum of data) {
let stack = 0
// A row of holes is no stack at all — not a stack of zero, which a
// fitted axis would otherwise have to reach down to.
let stacked = false
for (const key of keys) {
const raw = datum[key]
if (typeof raw !== "number" || !Number.isFinite(raw)) continue
if (options.stacked) {
stack += raw
stacked = true
} else {
if (raw < min) min = raw
if (raw > max) max = raw
}
}
if (stacked) {
if (stack < min) min = stack
if (stack > max) max = stack
}
}
return { min, max }
}
/** A y axis' explicit domain and ticks for the data it has to hold. */
export function yAxisScale(
data: ChartDatum[],
series: ChartSeries[],
options: {
stacked?: boolean
compare?: boolean
count?: number
annotations?: ChartAnnotation[]
/** Where the axis starts; zero unless the chart asks to fit its data. */
baseline?: ChartBaseline
} = {}
): { domain: [number, number]; ticks: number[] } {
const { min, max } = chartExtent(data, series, options)
// A goal line above every plotted point still has to fit inside the plot.
let low = min
let high = max
for (const annotation of options.annotations ?? []) {
for (const value of annotationValues(annotation)) {
if (value < low) low = value
if (value > high) high = value
}
}
return niceDomain(low, high, options.count ?? 4, { baseline: options.baseline })
}
// Below these the axis band costs more than the scale it prints: the labels
// crowd the plot at 120px tall and eat a third of the width at 240px wide.
const MIN_AXIS_HEIGHT = 120
const MIN_AXIS_WIDTH = 240
/**
* Whether a y axis fits. Width 0 means "not measured yet" — on the server, and
* in a test with no layout — and is never taken as "too narrow".
*/
export function fitsYAxis(height: number, width = 0): boolean {
if (height < MIN_AXIS_HEIGHT) return false
return width === 0 || width >= MIN_AXIS_WIDTH
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { ChartContainer } from "@/components/ui/chart"
import { annotationRows, type ChartAnnotation } from "@/components/ui/chart-core"
import { fitsYAxis } from "@/components/ui/chart-scale"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"
/* -------------------------------------------------------------------------- */
/* The frame: measure, then draw */
/* -------------------------------------------------------------------------- */
// A ChartSkeleton is a legend, a plot and a row of tick labels; the two text
// rows and their gaps take about this much, so the skeleton reserves the same
// box the finished chart will fill.
const SKELETON_CHROME = 56
/** What a chart's body is told about the box it is being drawn into. */
export type ChartFrame = {
/** The plot's own width in px, or 0 before it has been measured. */
width: number
/** False until one layout has happened — the skeleton holds the space until then. */
measured: boolean
reduced: boolean
/** Whether the value axis both was asked for and fits in the box. */
yAxis: (asked: boolean) => boolean
}
export type ChartPlotProps = Omit<React.ComponentProps<typeof ChartContainer>, "children"> & {
height: number
/** The recharts chart to draw, given what is known about the box. */
children: (frame: ChartFrame) => React.ComponentProps<typeof ChartContainer>["children"]
}
/**
* A chart's box: measured first, drawn second.
*
* Nothing can be measured on the server, so the skeleton holds the chart's
* space until the first client layout, and the ChartContainer inside then
* measures its own box and draws at that size — which is also what makes the
* mount fade a fade of the finished chart rather than of a mis-sized one. The
* width measured here is what decides whether a value axis fits, so that
* decision is handed to the chart body rather than guessed at.
*/
function ChartPlot({ height, className, style, children, ...props }: ChartPlotProps) {
const [width, setWidth] = React.useState(0)
const [measured, setMeasured] = React.useState(false)
const plot = React.useRef<HTMLDivElement | null>(null)
// Asked of the plot itself, so a frame that asked for less motion on its own
// wrapper is heard; the document root would not have carried it.
const reduced = useReducedMotion(plot)
const ref = React.useCallback((node: HTMLDivElement | null) => {
plot.current = node
if (!node) return
const read = () => setWidth(Math.round(node.getBoundingClientRect().width))
read()
// One layout has now happened, whether or not it produced a number: an
// environment with no layout engine reports 0 forever, and holding a
// skeleton there would mean no chart is ever drawn at all.
setMeasured(true)
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(read)
observer.observe(node)
return () => observer.disconnect()
}, [])
return (
<div ref={ref} data-slot="chart-plot" className="w-full">
{measured ? (
<ChartContainer
// The one piece of motion a chart has: it fades in when it first has a
// size, and never again — a re-render on new data leaves the element
// mounted, so the animation does not restart. Duration and easing are
// the theme's tokens, which are zeroed under reduced motion.
className={cn(
!reduced && "animate-in fade-in-0 duration-(--duration-base) ease-(--ease-standard)",
className
)}
style={{ height, ...style }}
{...props}
>
{children({
width,
measured,
reduced,
yAxis: (asked: boolean) => asked && fitsYAxis(height, width),
})}
</ChartContainer>
) : (
<ChartSkeleton height={Math.max(80, height - SKELETON_CHROME)} />
)}
</div>
)
}
export type ChartRowsProps = {
slot: string
label: string
rows: { label: string; readings: string }[]
annotations?: ChartAnnotation[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
/**
* A chart's numbers as text. Every plotted point, then every annotation drawn
* over them — a goal line a sighted reader can see has to be readable too.
*/
function ChartRows({
slot,
label,
rows,
annotations,
valueFormatter,
indexFormatter,
}: ChartRowsProps) {
const notes = annotationRows(annotations, { valueFormatter, indexFormatter })
return (
<ul data-slot={slot} aria-label={label} className="sr-only">
{rows.map((row, i) => (
<li key={`row-${i}`}>{`${row.label}: ${row.readings}`}</li>
))}
{notes.map((note, i) => (
<li key={`note-${i}`} data-slot="chart-annotation-row">
{note}
</li>
))}
</ul>
)
}
export { ChartPlot, ChartRows }