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.
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
| Prop | Type | Default | Purpose |
|---|---|---|---|
id | string | — (required) | The scale id a layer binds to via axis. First-declared = row default. |
side | 'left' | 'right' | 'left' | Which gutter the axis sits in. |
label | string | id | Axis title / unit. |
labelPlacement | 'rotated' | 'top' | 'rotated' | rotated = vertical strip on the outer edge; top = horizontal atop. |
min / max | number | auto-fit | Explicit domain bounds; omit to fit the bound layers. |
pad | number | 0 | Fractional headroom added each side of the resolved domain. |
format | AxisFormat (d3 specifier or (v) => string) | scale default | Tick-label and cursor-readout formatting. Hoist an inline function. |
ticks | ReadonlyArray<{ at: number; label: string }> | auto | Explicit ticks — drives both labels and gridlines. [] draws none. |
tickCount | number | height-derived | Target auto-tick count (a ticks(count) hint). Omit ⇒ derived from row height, so a short strip isn't crushed. ticks overrides it. |
boundaryLabels | boolean | true | false drops just the top & bottom extreme labels (gridlines stay). |
width | number | 50 | Gutter width in CSS px. |
color | string | theme | This axis's tick + title colour (presentation-only). |
onMouseEvent | (info: AxisMouseEvent) => void | — | Mouse events on the gutter, with the axis value under the pointer. See Clicking an axis. |
onBoundsChange | (bounds: [number, number] | null) => void | — | A 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
| Prop | Type | Default | Purpose |
|---|---|---|---|
side | 'top' | 'bottom' | 'bottom' | Which edge. Declaration order stacks multiple strips. |
label | string | — | Centred axis title. |
format | AxisFormat | container's | Tick/cursor formatting, resolved against the axis kind. |
ticks | ReadonlyArray<{ at: number; label: string }> | auto | Explicit 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. |
color | string | theme | Tick / label / rule / title colour — the lever for a stacked dual axis. |
height | number | fit-to-content | Strip height in px. |
onMouseEvent | (info: AxisMouseEvent) => void | — | Mouse 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:
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:
| Field | Type | What it is |
|---|---|---|
event | React.MouseEvent | The raw event; event.type says which one fired. |
axis | 'x' | 'y' | Which axis fired, so one handler can serve both. |
id | string | undefined | The axis's id. A <YAxis> always has one; an <XAxis> has none. |
value | number | The pointer's pixel inverted through that axis's scale — epoch ms on a time axis, the number on a value axis. |
label | string | value on that axis's readout channel — the category name on a category axis. |
Four details worth knowing:
valueis 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), solabelnames 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,valueis the underlying value, not the derived unit — apply the sametransform.toto read it in the strip's own language. - Two stacked x-axes look identical (
idisundefinedfor both), so close over the distinction:onMouseEvent={(e) => onAxis('delta', e)}. labelis the readout channel, not the tick text. It always agrees with what the cursor pill says at that pixel, which means a containercursorFormatshapes it (the documentedcursorFormat→ axisformat→ container precedence). Set one andlabelreads 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
- Value axis · Category axis · Trading-time axis — the three x-axis kinds.
- Storybook:
Axes(every Y/X prop and state) andAxes/DualX. - The API reference for the
exhaustive
YAxisProps/XAxisPropstypes.