Skip to main content

Axes

A chart has two kinds of axis, owned at two different levels. The x axis is shared by the whole <ChartContainer> — one scale every row draws against, so cursors and ranges line up across stacked plots. Each <ChartRow> owns its own y axis (or axes), so rows with different units stack cleanly.

This page is the reference for declaring and binding axes. The three axis kinds that change what the x axis means each get their own page:

  • Value axis — key by a monotonic quantity (distance, strike) instead of time.
  • Category axis — an ordinal axis, one slot per category.
  • Trading-time axis — a session-aware time axis with closed-market gaps collapsed.

And one that changes how the x axis reads rather than what it means:

  • Duration axis — label a time or value axis as offsets from a zero point (00:00 00:05 00:10) instead of absolute values.

Y axes

A y axis is a <YAxis> inside a <ChartRow>. A draw layer binds to it by id through its axis prop — id picks the scale, while as picks the style (they're separate channels). Declare several with distinct ids and sides for a dual-axis row; placement follows side, not JSX order, and the first-declared axis is the row's default for any layer that names none.

src/examples/learn-02-dual-axis.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';

export default function DualAxis() {
const theme = useSiteChartTheme();
const series = singleHostSeries();

return (
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
<ChartRow height={220}>
<YAxis id="pct" side="left" label="cpu" format=".0%" />
<YAxis id="ms" side="right" label="latency (ms)" format=",.0f" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" as="primary" />
<LineChart
series={series}
column="latency"
axis="ms"
as="secondary"
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
<ChartRow height={200}>
<YAxis id="pct" side="left" format=".0%" />
<YAxis id="ms" side="right" format=",.0f" />
<Layers>
<LineChart series={s} column="cpu" axis="pct" />
<LineChart series={s} column="latency" axis="ms" />
</Layers>
</ChartRow>

If a row has no <YAxis> at all, the layers still draw against an implicit auto-fitting axis — you just get no gutter or ticks.

<YAxis> props

PropTypeDefaultPurpose
idstring— (required)The scale id a layer binds to via axis. First-declared = row default.
side'left' | 'right''left'Which gutter the axis sits in.
labelstringidAxis title / unit.
labelPlacement'rotated' | 'top''rotated'rotated = vertical strip on the outer edge; top = horizontal atop.
min / maxnumberauto-fitExplicit domain bounds; omit to fit the bound layers.
padnumber0Fractional headroom added each side of the resolved domain.
formatAxisFormat (d3 specifier or (v) => string)scale defaultTick-label and cursor-readout formatting. Hoist an inline function.
ticksReadonlyArray<{ at: number; label: string }>autoExplicit ticks — drives both labels and gridlines. [] draws none.
tickCountnumberheight-derivedTarget auto-tick count (a ticks(count) hint). Omit ⇒ derived from row height, so a short strip isn't crushed. ticks overrides it.
boundaryLabelsbooleantruefalse drops just the top & bottom extreme labels (gridlines stay).
widthnumber50Gutter width in CSS px.
colorstringthemeThis axis's tick + title colour (presentation-only).
onMouseEvent(info: AxisMouseEvent) => voidMouse events on the gutter, with the axis value under the pointer. See Clicking an axis.
onBoundsChange(bounds: [number, number] | null) => voidA gutter gesture scaled this axis: the [min, max] reached, or null for back-to-auto. Providing it makes the axis controlled.

format is the one prop whose inline function form must be hoisted or useCallback'd — it's the only prop the layout's structural change-detection can't value-compare, so a fresh function each render re-registers the axis.

The x axis

You don't declare the x axis to get one: <ChartContainer> renders a <TimeAxis> at the bottom automatically (showAxis defaults to true). Set showAxis={false} for a bare plot, or to place your own <XAxis> — for a top axis, a label, custom ticks, or a second (transformed) strip.

Its kind is inferred from the data, never set by a prop: a TimeSeries gives a time axis, a ValueSeries a value axis, and BarChart categories a category axis. Every layer in a container must agree on the kind — a mix throws. <TimeAxis> and <CategoryAxis> are both just <XAxis> presets; the kind follows the data regardless of which you render.

<XAxis> props

PropTypeDefaultPurpose
side'top' | 'bottom''bottom'Which edge. Declaration order stacks multiple strips.
labelstringCentred axis title.
formatAxisFormatcontainer'sTick/cursor formatting, resolved against the axis kind.
ticksReadonlyArray<{ at: number; label: string }>autoExplicit ticks in axis-value units.
transform{ to(v): number; from(u): number }Relabel the same scale into a derived unit (a second tick layout).
align'auto' | 'center' | 'right''center'Horizontal tick-label placement.
dateStyle'flat' | 'stacked''flat'Time axis only: flat promotes date context inline on one row (the TradingView look); stacked uses a second boundary row. See Trading-time axis.
colorstringthemeTick / label / rule / title colour — the lever for a stacked dual axis.
heightnumberfit-to-contentStrip height in px.
onMouseEvent(info: AxisMouseEvent) => voidMouse events on the strip, with the axis value under the pointer. See Clicking an axis.

Dual x-axes — transform

A second <XAxis> with a transform relabels the same pixel scale into a derived unit — one scale, two tick layouts, never two scales. The to / from pair are monotonic inverses and may be nonlinear:

src/examples/charts-value-axis-dual.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
ScatterChart,
XAxis,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { SPOT, smileChain } from './lib/value-axis-fixtures';

export default function ChartsValueAxisDual() {
const theme = useSiteChartTheme();
const chain = smileChain();

return (
<ChartContainer showAxis={false} width={560} theme={theme}>
{/* A second <XAxis> relabels the SAME shared scale into a derived
unit via `transform` — one pixel mapping, two tick layouts. Here
strike (below) and moneyness = strike / spot (above) are linearly
related, so the top strip's ticks land evenly too. */}
<XAxis
side="top"
transform={{ to: (k) => k / SPOT, from: (m) => m * SPOT }}
format=".2f"
label="Moneyness"
/>
<ChartRow height={220}>
<YAxis id="iv" label="implied vol" format=".1%" width={60} />
<Layers>
<LineChart series={chain} column="fair" curve="natural" />
<ScatterChart series={chain} column="fair" id="fair" />
</Layers>
</ChartRow>
<XAxis label="Strike" format=",.0f" />
</ChartContainer>
);
}

Declaration order stacks the strips (before <ChartRow> → above the plot, after → below); gridlines always follow the container's primary ticks. This is relabeling, not the axis-kind mixing that throws — the scale is unchanged.

Time formatting

Two independent channels. Labels: ChartContainer's timeFormat (or a per-instance <XAxis format>) shapes the tick labels; a custom label format owns the labels, so it opts the axis out of the dateStyle date styles (flat / stacked). Readout: ChartContainer's cursorFormat shapes the crosshair pill, marker indicators, and annotation auto-labels independently, and does not disqualify a date style. Omit both and the readout defaults to a grain-aware format — a day-or-coarser axis reads a date, a sub-day axis date + clock — so a daily bar never reads a foreign-timezone time-of-day.

Clicking an axis

Both axes take an onMouseEvent handler, and it carries the thing you can't work out from the DOM event alone: the axis value under the pointer.

<YAxis
id="price"
onMouseEvent={({ event, id, value, label }) => {
if (event.type !== 'click') return;
setThreshold({ axis: id, at: value }); // `label` is "184.20", as the axis prints it
}}
/>

One handler receives every mouse event on the strip — click, dblclick, contextmenu, mousedown/mouseup, mousemove, mouseenter/mouseleave — so switch on event.type and ignore the rest. The event itself is the ordinary React one: modifier keys, button, preventDefault(). Nothing is attached when you omit the prop, so an axis that doesn't opt in pays nothing for the move events.

What arrives:

FieldTypeWhat it is
eventReact.MouseEventThe raw event; event.type says which one fired.
axis'x' | 'y'Which axis fired, so one handler can serve both.
idstring | undefinedThe axis's id. A <YAxis> always has one; an <XAxis> has none.
valuenumberThe pointer's pixel inverted through that axis's scale — epoch ms on a time axis, the number on a value axis.
labelstringvalue on that axis's readout channel — the category name on a category axis.

Four details worth knowing:

  • value is continuous, not snapped to a tick — it lands between them. The exception is a category x-axis, whose scale inverts to the nearest band centre (i + 0.5), so label names the category you clicked. A categorical row (horizontal bars, categories on y) is a plain slot scale and doesn't snap: Math.floor(value) is its slot index.
  • On a transformed x-axis, value is the underlying value, not the derived unit — apply the same transform.to to read it in the strip's own language.
  • Two stacked x-axes look identical (id is undefined for both), so close over the distinction: onMouseEvent={(e) => onAxis('delta', e)}.
  • label is the readout channel, not the tick text. It always agrees with what the cursor pill says at that pixel, which means a container cursorFormat shapes it (the documented cursorFormat → axis format → container precedence). Set one and label reads in the precise form the pill does, which may be finer than the terse tick beneath it.

Axes take no className, so use the data-axis hooks to style one — e.g. [data-axis='x'] { cursor: pointer }, or [data-axis-id='price'] for a single y gutter.

See also