Visual

Candlestick chart

Sand pours into each candle's body and resolves into a solid open-close block, while the high-low wicks are stroked solid throughout. Every candle is colored rising or falling by a configurable difference rule, and a dashed live-price line tracks the latest actual.

Three structural traits. (1) data is an OhlcDataSet — four prices per period, not one y — so it gets its own additive type next to Point/MeshPoint. (2) Color comes from a direction rule, not a series palette: the grain palette is [rising, falling] and the legend's two entries describe those groups, so clicking one isolates every rising (or every falling) candle. (3) Series are a selector — two instruments can't share an x-slot legibly, so one series is drawn at a time and the series slider picks which, exactly like the pie chart.

Constructor

new CandlestickChart(host: HTMLElement, config: CandlestickChartConfig)

Mounts a canvas into host and boots the best backend (WebGPU → Canvas2D). Only data is required.

Data — OhlcDataSet

interface Candle {
  x: Scalar;          // period start: a Date, a number, or a category label
  open: number;
  high: number;
  low: number;
  close: number;
  actual?: number;    // live / last traded price — drives the live-price line
}

interface CandleSeries { key?: Scalar; candles: Candle[]; }
interface OhlcDataSet  { series: CandleSeries[]; xType?: FieldType; }

actual is orthogonal to close: on a streaming feed the newest candle's close is still provisional while actual is the price right now. It never affects a candle's own geometry — only the live-price line, the price-axis domain (so the line always stays on screen) and the hover readout.

A transposed high/low is not an error: the wick extent is taken from the min/max of all four prices, so a swapped pair still draws a sensible candle rather than an inverted one.

Config — top level

PropertyTypeDefaultDescription
data requiredOhlcDataSetInstruments and their candles. See above.
spacing'band' | 'time''band'How candles are positioned along X. See below.
candlesCandleStyleConfigColors, color rule, body geometry, wicks and the solid layer. See below.
actualActualConfigoffLive-price line. See below.
seriesIndexnumber0Which instrument is drawn; clamped into range.
maxCandlesnumber5000Cap on candles drawn per series; extras are dropped.
maxSeriesnumber1000Cap on instruments the slider can address.
sliderSliderConfigonThe instrument picker. Same block as the pie chart's slider, and hidden anyway when there is one series. When it shares the bottom edge with a shown X axis it is placed below the axis labels, not on them.
grainDensitynumber0.8Grain budget multiplier; candles share grainDensity × 20 000 grains by body area, so adding candles subdivides the same sand.
maxGrainsnumber100000Hard grain ceiling.
backgroundstringtransparentCanvas clear color.
grainobjectsizePx (2.4), shape ('disc'), jitter (0.6), settleJitter (0.004). See bar chart → grain.
animationobjectTiming. See below.
interactionobjecthover and dim as on every other chart, plus mouse hooks.
axes{ x?, y? }offSame shape as bar chart. X is the period axis (and styles the slider's ticks); Y is the price axis.
legendLegendConfigoffTwo entries — Rising and Falling. With interactive, clicking one isolates that group.
titleTitleConfigoffChart title; shares the legend's placement vocabulary.
currentValueobjectoffSame as bar chart; the default readout is date · O H L C · live.
fpsFpsConfigoffSame as bar chart.
panZoomPanZoomConfigoffDrag to pan, wheel/UI to zoom. Shared pan & zoom. axes: 'x' is the usual choice for a price series.
backend'auto' | 'webgpu' | 'webgl2' | 'canvas2d''auto'Force a rendering backend.

candles

One block for everything about a candle: what color it is, what its body spans, and the solid layer that resolves as the sand fades out. Mirrors the bar chart's bars.

PropertyTypeDefaultDescription
risingstring'#2eb872'Color of a rising candle (CSS).
fallingstring'#e0555c'Color of a falling candle (CSS).
directionCandleDirection | fn'openClose'Which difference decides rising vs falling. See below.
body'openClose' | 'lowHigh''openClose'Which prices the body spans. 'lowHigh' makes each candle a plain range bar and leaves no wick to draw.
widthnumber0.62Body width as a fraction of the x step, 0..1.
fill.opacitynumber1Solid body fill opacity. The block's presence is what turns the fill on; the color is always the candle's own.
border.showbooleantrue*Body outline. *Default true when a border block is present.
border.width / border.opacitynumber1 / 1Outline stroke width (CSS px) and opacity.
wick.showbooleantrueHigh-low wicks. On even with no candles block at all — see the note below.
wick.width / wick.opacitynumber1 / 1Wick stroke width (CSS px) and opacity.
revealRevealConfigTiming of the particle→solid-body crossfade: start ('afterPour'), duration (500 ms), ease, grainsTo (0). Shared by every visual.
Wicks are not part of the reveal. They are the only thing carrying the high and low prices, so they are stroked solid from the first frame — while the bodies are still falling sand — and never fade with reveal. Only the body's fill and border resolve as the grains fade. Omit both and you get a permanent sand chart with solid wicks (see the sand-only example).
A doji (open === close) still gets a minimum body height, so it carries grains and draws a visible line rather than vanishing.

Color rules — candles.direction

Which difference decides a candle's color. All the rules that look backwards fall back to 'openClose' for the first candle of a series, since nothing precedes it.

ValueRising when…
'openClose' defaultclose >= open — the classic candle rule.
'closeClose'close >= the previous candle's close: did the level rise since the last period.
'lowHigh'this candle's low-high range midpoint is at or above the previous candle's midpoint: did the whole trading range shift up, regardless of where the period opened and closed.
'closeInRange'the close sits in the upper half of the candle's own low-high range. Depends on no other candle.
(candle, previous, index) => booleanAnything you like — a moving average, an external signal. Passing a function in place of a name plugs in a custom rule without the chart changing, the same way the scatter chart takes a custom Approximation.

actual — the live-price line

A horizontal rule across the plot at the latest Candle.actual in the drawn series (the last candle that carries one), with a value marker on the price axis. Off by default: without an actual block the values still travel through hover and events, but nothing is drawn.

PropertyTypeDefaultDescription
showbooleantrue**True when an actual block is present.
colorstringcurrentValue colorLine and marker color (CSS).
widthnumber1Line width, CSS px.
opacitynumber0.9Line opacity.
dashnumber[] | false[4, 4]Dash pattern in CSS px; false for a solid rule.
markerbooleantrueDraw the value marker on the Y axis (needs axes.y.show).
format(v: number) => stringcompact numberFormat the price for that marker.

The live price is folded into the price-axis domain, so a line that has run past every candle's range still stays on screen.

spacing

ValueBehavior
'band' defaultEvery candle gets an evenly-spaced slot, so weekends and holidays collapse instead of leaving holes — standard trading-chart behavior. Axis ticks are the periods themselves, thinned by axes.x.ticks exactly like the bar chart's category axis.
'time'Candles sit at their real x value on a continuous LinearScale/TimeScale, so gaps in the data show as gaps. Body width comes from the median gap between periods; ticks come from the scale.
A categorical x has no continuous position, so 'time' always degrades to 'band' for it. The layout reports what it actually used.

animation

Units: duration, stagger and morphDuration are in milliseconds.

Same as the bar chart, with the three-way twists named for candles:

PropertyTypeDefaultDescription
durationnumber (ms)900Per-grain travel time.
staggernumber (ms)500Spread of pour start times.
ease'linear' | 'easeOutCubic' | 'easeOutQuint''easeOutCubic'Grain travel easing.
morphDurationnumber (ms)duration + staggerBody-tween + grain-fade window for a data or instrument change.
reflow'translate' | 'reshuffle' | 'withCandle''translate'How an unchanged period's grains move. 'withCandle' locks them to the body: rigid move, no delay, no grain-fade flash.
morphGrainsbooleantruefalse snaps grains to their new positions so only the solid body tweens.
enter'pour' | 'rise' | 'continue''pour'How an added candle's grains arrive. 'continue' skips the emergence — the right choice for a streaming feed.
exit'fall' | 'vanish''fall'How a removed candle's grains leave.
Candle identity is the period (x), not the array position. That is what makes a rolling window animate correctly: dropping the oldest bar and appending a new one shifts every index, but each surviving period keeps its identity, so unchanged candles slide instead of being treated as removed-and-re-added. A candle with no predecessor opens from a flat line at its own level.

Overriding mouse events — interaction.mouse

Every built-in mouse reaction can be cancelled or replaced, through the same hook contract the wind rose uses (core/interaction/mouse.ts). Each hook receives a context —

{
  meta: CandleMeta | null;   // the mark under the pointer
  native: PointerEvent;      // modifier keys, coordinates, preventDefault
  px: { x: number; y: number };   // CSS px, relative to the chart element
  defaultAction(): void;    // run the built-in behavior — idempotent
}

Return false to suppress the built-in behavior; return anything else — including nothing — to let it run after the hook, unless the hook already called defaultAction() itself.

HookMarkBuilt-in behavior it can cancel
onCandleHoverCandleMetaThe hover highlight and the current-value readout.
onCandleClickCandleMetaNothing built in — the chart has no click behavior of its own, so this is a pure observation point.
onLegendClicknumber (entry index)The isolate/dim toggle.
onSliderSeeknumber (target index)The instrument change from a slider drag or click.
Hooks are not a replacement for the events. hover, click, seriesChange and seriesFocus fire even when a hook cancels the default — an observer is not an override, and a caller who suppresses the built-in selection should still be able to hear about the gesture.

Shared blocks

axes, legend, title, currentValue, interaction.hover, interaction.dim and fps are the same config the bar chart documents — see bar chart → axes / legend / currentValue / fps. Pan & zoom is the shared panZoom block.

axes.x does double duty: it styles the period axis and the series slider's ticks (count, format, label, font, color), the same way it styles the pie chart's slider.
The price axis has no meaningful baseline — prices don't start at zero — so axes.y's domain is the candles' own low/high extent (plus the live price), padded ~6% on each side, rather than being anchored to 0.

Methods

MethodDescription
whenReady(): Promise<void>Resolves once the backend is initialized and the first frame is scheduled.
get backend'webgpu' | 'webgl2' | 'canvas2d' | null.
on(event, fn)Subscribe to hover, click, seriesChange or seriesFocus; returns an unsubscribe function.
update(data)Replace the whole OhlcDataSet; candles morph and the bodies tween (no re-pour).
update(patches, seriesIndex?)Set prices on existing candles in place, matched by x. Omitted fields keep their values, so a feed can patch just close/actual. Defaults to the series on screen.
add(candles, seriesIndex?)Append one or more Candles (a series index past the end creates one); new candles arrive per animation.enter.
remove(refs, seriesIndex?)Remove candles by index (negative = from the end) or { x } match; the rest reflow.
setSeriesIndex(i)Show another instrument — candles morph across and the slider handle eases over, the same code path a drag takes. Fires seriesChange.
getSeriesIndex() / seriesCount / getSeriesKeys()The current instrument, how many the slider addresses, and their keys.
focusSeries(index)Isolate a direction group0 rising, 1 falling — at full opacity while the other dims to interaction.dim.opacity; null clears it. Those groups are what the legend describes (the pie chart's focusSeries targets its slices the same way, not the slider's series). Fires seriesFocus.
getFocusedSeries()number | null — the isolated direction group, or null.
repour()Re-run the pour-in animation with the current data (no morph).
getData(): OhlcDataSetDeep-cloned snapshot of the current dataset.
dispose()Cancel the loop, remove listeners, free the backend and DOM.

It also implements the shared PanZoomable interface: getView(), setView(v), panBy(), panTo(), zoomBy(), zoomTo(), resetView(), isPanZoomEnabled().

Events

hover carries { candle: CandleMeta | null } and click carries { candle, event }. Hit-testing is by column: anywhere in a candle's slot selects it, the way a trading chart's crosshair behaves, rather than demanding a hit on the thin body.

// CandleMeta — metadata for one drawn candle
{
  candleId: number;          // index within the drawn series; also the grain barId
  indexInSeries: number;
  seriesIndex: number;
  seriesKey: Scalar | undefined;
  xValue: Scalar;
  open: number; high: number; low: number; close: number;
  actual: number | undefined;
  rising: boolean;            // what the direction rule decided
  x0: number; x1: number; cx: number;      // body edges + center, layout space [0,1]
  bodyLow: number; bodyHigh: number;   // body extent, layout space
  wickLow: number; wickHigh: number;   // full price range, layout space
  color: RGBA;
}

seriesChange carries { index, key } (the instrument the slider moved to) and seriesFocus carries { index: number | null } (the isolated direction group).

Examples

Solid candles with axes and a live price

new CandlestickChart(el, {
  data: {
    series: [{
      key: 'ACME',
      candles: [
        { x: new Date('2026-01-05'), open: 120, high: 124, low: 119, close: 123 },
        { x: new Date('2026-01-06'), open: 123, high: 125, low: 121, close: 121.5, actual: 122.2 },
      ],
    }],
  },
  candles: {
    fill: { opacity: 0.9 },
    border: { width: 1 },
    reveal: { duration: 500, grainsTo: 0.12 },
  },
  actual: { show: true },
  axes: { x: { show: true, ticks: 6 }, y: { show: true, gridLines: true } },
});

Sand only — no solid bodies

// No fill and no border: the bodies stay sand forever, only the wicks are solid.
new CandlestickChart(el, {
  data,
  grainDensity: 2.2,
  candles: { width: 0.8, reveal: { grainsTo: 1 } },
});

Coloring by the previous close, not the open

new CandlestickChart(el, {
  data,
  candles: { direction: 'closeClose', rising: '#2eb872', falling: '#e0555c' },
});

A custom color rule

// Rising while the close holds above a level your own code computes.
new CandlestickChart(el, {
  data,
  candles: {
    direction: (candle, previous, index) => candle.close >= movingAverage(index),
  },
});

Range bars instead of candles

new CandlestickChart(el, {
  data,
  candles: { body: 'lowHigh', width: 0.5, fill: { opacity: 0.7 } },
});

Streaming: tick the live price, roll the window

const chart = new CandlestickChart(el, {
  data,
  candles: { fill: {}, border: {} },
  actual: { show: true },
  animation: { enter: 'continue' },   // new bars appear settled, no pour
});

// Every tick: patch only what moved on the bar still being filled.
feed.on('price', ({ x, price }) => {
  chart.update([{ x, close: price, actual: price }]);
});

// Every period: append the new bar and drop the oldest — surviving candles slide.
feed.on('bar', (bar) => {
  chart.add(bar);
  chart.remove(0);
});

Switching instruments

const chart = new CandlestickChart(el, {
  data: { series: [{ key: 'ACME', candles: acme }, { key: 'GLOBEX', candles: globex }] },
  slider: { show: true, position: 'bottom' },
  axes: { x: { show: true, label: 'instrument' } },
});

chart.on('seriesChange', ({ index, key }) => console.log('now showing', key));
chart.setSeriesIndex(1);   // same animation as dragging the slider

Isolating rising or falling candles

new CandlestickChart(el, {
  data,
  candles: { fill: {} },
  legend: { show: true, interactive: true },   // entries: Rising / Falling
  interaction: { dim: { opacity: 0.15 } },
});

chart.focusSeries(0);   // show only rising candles at full opacity

Driving your own tooltip (overriding the mouse events)

new CandlestickChart(el, {
  data,
  interaction: {
    mouse: {
      onCandleHover({ meta, px }) {
        myTooltip(meta, px);
        return false;   // suppress the built-in readout (the hover event still fires)
      },
      onCandleClick({ meta }) {
        selectPeriod(meta?.xValue);
      },
      onSliderSeek({ meta: index, defaultAction }) {
        if (isLoaded(index)) defaultAction();   // switch now…
        else load(index).then(defaultAction);   // …or once the data arrives
        return false;
      },
    },
  },
});

© 2026 · License · Data model →