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.
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.
panZoomstill works on y; the x half is gated off, because sliding between named slots isn't a gesture the axis has a meaning for. rangestops 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.xScalestops 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 unique —
labelis 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
categorieswhen that is given (a mismatch throws, like the axis-kind mix). - Overlay marks belong at
i + 0.5, noti—iis the slot's left edge, so a series keyed there draws half a slot to the left of the bars. - No
formaton the axis — style the names at the data level (CategoryDatum.label), not with a d3 specifier. categoriesis one ofBarChart's three input modes (series/bins/categories) — pass exactly one.
See also
- Axes overview — declaring and binding axes.
- Learn charts, chapter 9 — the tutorial.
- The categorical charts guide — a worked end-to-end build.
- Storybook:
Axes/CategoryAxis—Tickers,HighCardinality,CrowdedLabels,Select,Signed,Transpose,TransposeScrub. - Storybook:
Category axis/Container categories—Default,LineOverBars,PointsOverBars,EnvelopeOverBars,CappedPitch,ManyThinnedLabels,MultiRow.