Skip to main content

Category axis

A category axis is ordinal: one slot per named category, in order, instead of a continuous time or value scale. You get one two ways: hand BarChart a categories array and the container infers the 'category' kind from the data, the same way it infers time or value; or declare the slots on the container with <ChartContainer categories>, which is what lets non-bar layers share the axis.

For the tutorial pass see Learn charts, chapter 9; for the systematic prop-by-prop walk see the Axes/CategoryAxis Storybook group.

src/examples/learn-09-category-axis.tsx
import {
BarChart,
ChartContainer,
ChartRow,
Layers,
YAxis,
transposeRow,
} from '@pond-ts/charts';
import { TimeSeries } from 'pond-ts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';

// A wide row — one column per host, the shape transposeRow reads. A real
// source would be the last row of a partitioned rollup; this is a fixed
// snapshot for the demo.
const wideSchema = [
{ name: 'time', kind: 'time' },
{ name: 'api-1', kind: 'number' },
{ name: 'api-2', kind: 'number' },
{ name: 'worker-1', kind: 'number' },
] as const;

function latestCpuByHost() {
return new TimeSeries({
name: 'latest-cpu',
schema: wideSchema,
rows: [[Date.UTC(2026, 0, 12, 10, 30), 0.34, 0.48, 0.61]],
});
}

export default function LearnCategoryAxis() {
const theme = useSiteChartTheme();
const data = transposeRow(latestCpuByHost(), { at: 'last' });

return (
<ChartContainer width={560} theme={theme}>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" min={0} />
<Layers>
<BarChart categories={data} gap={8} />
</Layers>
</ChartRow>
</ChartContainer>
);
}

The data shape — CategoryDatum[]

BarChart categories takes an array of { label, value }:

interface CategoryDatum {
label: string; // the category name — the stable identity; must be unique
value: number; // the bar height (may be negative)
}

label is both the tick and the selection identity, so labels must be unique. Order is presentation order — the axis lays out slots left to right in array order.

You can hand-build the array, or derive it. transposeRow reads one row of a wide TimeSeries (a column per category) across into CategoryDatum[] — reach for it when you already have a wide rollup row and want its columns read off automatically:

const data = transposeRow(latestCpuByHost(), { at: 'last' });
// [{ label: 'api-1', value: 0.34 }, { label: 'api-2', value: 0.48 }, …]

at defaults to 'last' (the newest row); pass 'first', an index, or a { time } to read a different row, and columns to pick/order a subset.

Rendering the axis

You usually don't render an axis element at all — the auto x-axis becomes categorical once a category layer registers, drawing each label under its slot. <CategoryAxis> is a thin <XAxis> preset; render it only for a label, side="top", or a custom height:

<ChartContainer width={560}>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" min={0} />
<Layers>
<BarChart categories={data} gap={8} />
</Layers>
</ChartRow>
</ChartContainer>

A d3 format doesn't apply on a category axis — the labels come from the data, so customize them via each CategoryDatum.label.

Under the hood — the band scale

The category axis is a band scale (scaleBand) with a deliberately numeric domain: slot i spans [i, i+1], so the pixel mapping stays linear and every continuous-axis mechanism (cursor, selection, barSpanPx) works unchanged. The category-ness lives only in the scale's ticks() (band centres at i + 0.5), invert() (snap to the nearest centre), and label(v) (slot → name). You rarely touch scaleBand directly; it's what makes an ordinal axis reuse the whole continuous-axis machinery.

Overlaying other layers — <ChartContainer categories>

The axis above is inferred from a category layer, and that has a limit: only <BarChart categories> and a horizontal heat map report an ordinal x-kind, and a container throws on a mixed kind — so a line, a point or an envelope over categorical bars is not expressible that way.

Declare the slots on the container instead, and any value-keyed layer can live on them:

const tickers = ['AAPL', 'MSFT', 'NVDA', 'AMZN'];

// Key the overlay to slot coordinates — slot i's centre is i + 0.5.
const marks = ValueSeries.fromColumns({
name: 'target',
schema: [
{ name: 'slot', kind: 'value' },
{ name: 'target', kind: 'number' },
] as const,
columns: {
slot: tickers.map((_, i) => i + 0.5),
target: [45, 30, 52, 31],
},
});

<ChartContainer width="auto" categories={tickers}>
<ChartRow height={200}>
<YAxis id="v" min={0} />
<Layers>
<BarChart categories={data} />
<LineChart series={marks} column="target" axis="v" />
</Layers>
</ChartRow>
</ChartContainer>;

The container now owns the ordinal domain, so <BarChart categories> is optional — the axis is categorical even with only value-keyed layers on it.

Why this is a container prop and not just "key your layers to integers." The hand-rolled version supplies its own tick labels, and that forfeits two things the axis already does: <XAxis> label thinning (gated on a category axis with no custom ticks) and the maxBandWidth / bandAlign slot packing. Declaring the categories keeps both.

Note the container's categories is a list of names (string[]), unlike <BarChart categories>, which takes { label, value } data. The container names the slots; the bar layer fills them.

Two things still error, deliberately:

  • A time-keyed layer — a timestamp has no slot to sit in.
  • A category layer that disagrees with the prop, in content or order. The prop is authoritative, and a silent mismatch would draw bars under the wrong labels.

What declaring it costs

Both were already true of an inferred category axis, but this prop lets you opt a previously-continuous container into them:

  • x pan and zoom stop. panZoom still works on y; the x half is gated off, because sliding between named slots isn't a gesture the axis has a meaning for.
  • range stops applying to x. The domain is [0, n] from the slot count, so an x range is a no-op rather than an error. Show a subset by passing fewer categories.
  • xScale stops applying. 'log' / 'symlog' describe how a continuous x spaces its values; ordinal slots are evenly spaced by definition, so the kind is ignored (as it already is on a time axis).

The hazard this cannot catch

A value-keyed layer is taken at its word. Anything reporting 'value' is read as slot coordinates, so a layer whose x means something else will draw, in the wrong place, silently. The sharpest instance is a horizontal categorical <BarChart>, whose x is bar length rather than a coordinate — don't mix one into an ordinal container.

This is documented rather than enforced, and the reason is worth knowing: a guard for it was written and removed, because it tested binCategories — the generic "my y is ordinal" channel, which a vertical heat map sets for its rows. It therefore rejected a slot-keyed grid with named columns on x, which is a wanted layout (ordinal rows plus ordinal columns is just a 2-D grid). Nothing on a layer distinguishes "my x is a coordinate" from "my x is a magnitude", so there is no contradiction to detect.

One more edge: categories={[]} is an ordinal axis with no slots yet, not a fallback to time — so the axis kind doesn't flip (and rebuild every scale) when the data arrives. That's the useful reading for a loading state.

Sharp edges

  • Labels must be uniquelabel is the selection/identity key; duplicates collide.
  • One slot list per container — all category rows must agree on the same ordered list, and on the container's categories when that is given (a mismatch throws, like the axis-kind mix).
  • Overlay marks belong at i + 0.5, not ii is the slot's left edge, so a series keyed there draws half a slot to the left of the bars.
  • No format on the axis — style the names at the data level (CategoryDatum.label), not with a d3 specifier.
  • categories is one of BarChart's three input modes (series / bins / categories) — pass exactly one.

See also