Layout
A chart is composed from three nested primitives, each owning one part of the layout:
<ChartContainer>— the whole chart's width and the shared x axis. Holds one or more rows, stacked vertically.<ChartRow>— one horizontal band with its own height and y axes. Owns the left-to-right layout (axes around the plot).<Layers>— the z-stack inside a row; children draw back-to-front in author order.
The x axis is shared by the container; each row owns its y axis. This page is the reference for arranging rows and sizing the frame — for the axes themselves, see Axes.
Rows stack; the x axis is shared
Give the container multiple <ChartRow>s and they stack top to bottom, all
drawing against the one shared x axis (rendered once, under the last row).
Each row sets its own height; a rowGap on the container adds vertical space
between them:
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 TwoRow() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
return (
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
<ChartRow height={140}>
<YAxis id="pct" side="right" label="cpu" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" as="primary" />
</Layers>
</ChartRow>
<ChartRow height={100}>
<YAxis id="ms" side="right" label="latency (ms)" format=",.0f" />
<Layers>
<LineChart
series={series}
column="latency"
axis="ms"
as="secondary"
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
<ChartContainer range={s.timeRange()} width={560} rowGap={8}>
<ChartRow height={140}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={s} column="cpu" axis="pct" />
</Layers>
</ChartRow>
<ChartRow height={100}>
<YAxis id="ms" side="right" format=",.0f" />
<Layers>
<LineChart series={s} column="latency" axis="ms" />
</Layers>
</ChartRow>
</ChartContainer>
Because the x axis is shared, a cursor or a selected range lines up across every row — hovering the top panel moves the cursor on the bottom one too.
Horizontal layout within a row
Inside a row, each axis sits in the gutter its side names — left axes in
the left gutter, right axes in the right, with <Layers> (the plot area)
between. Placement follows side, not JSX position; author them in that order
anyway (side="left" before <Layers>, side="right" after) so the markup
reads the way it lays out. Declaring several <YAxis> gives a dual-axis row —
see Axes for binding layers to axes by id.
Gutters align across rows
Rows can have different axis gutters (a wide left label on one, none on another) and their plot areas still left-align. Each row reports its per-slot gutter widths; the container reserves the max width for each slot and pads the rows that don't use it. You don't manage this — it's why a multi-panel stack lines up on the x axis without hand-tuning widths.
Sizing the frame
width is either a pixel number or 'auto' — and an omitted width
means 'auto'. It is never "100%": the canvas renderer needs real pixels to
lay out ticks and slots before it draws.
'auto' fills the available width. The container renders a plain full-width
box, measures it, and mounts the chart at that pixel width, re-rendering as the
box resizes — so a flex column, a grid cell or the window all just work:
<ChartContainer width="auto">…</ChartContainer>
Nothing paints until a real width exists, so an auto container is briefly an empty box on the first layout pass. Pass a number when the width is already known (a fixed panel, a print layout, a test) — it skips the measure pass and paints on the first render.
The responsive-width recipe shows the same measurement done by hand, which is worth reading when you need the measured width for something besides the chart.
Height — the container-owned column ([PND-HEIGHT])
height works the same way: a pixel number or 'auto', with one
difference — an omitted height means unmanaged, the classic mode where
rows declare pixel heights and the container is their sum. (Width has no such
mode; a chart must have a width.)
With a height, the container renders as a flex column: the rows block
flexes, the x-axis strip keeps its natural height at the bottom, and
<ChartRow flex> rows (a bare <ChartRow> is flex={1}) divide what the
browser says is left. A full-bleed chart is zero arithmetic:
<ChartContainer width="auto" height="auto">
<ChartRow>
<YAxis id="v" />
<Layers>
<LineChart series={s} column="v" axis="v" />
</Layers>
</ChartRow>
</ChartContainer>
"What the browser says is left" is the feature, not an implementation
detail. The axis strip's height depends on its label, the theme's font
size, whether the tick ladder is showing its calendar band row at the current
grain, and how many marker pills stack — it is not a constant a caller can
subtract. Every consumer who tried carried a wrong number (20, 24, and
this site's own recipe had 22 — three values in the wild for one strip, and
the strip isn't even constant). CSS does the subtraction, so there is no
number to know.
Fixed-height rows keep their pixels inside a managed container, and non-row
children between rows (a draggable splitter) take their natural space — see
the resizable panels recipe for that shape,
which this reduces to a drag handler.
A flex row measures the height layout gave it and builds its y-scales from
that. Like width="auto", it paints nothing until its first measurement,
keeps its last non-zero height while hidden, and the container warns in dev
when a measured dimension stays 0 — which matters more for height, because
a flex-column child's height defaults to its content, so the unconstrained-
parent deadlock is the default there rather than an edge case.
For a full-height split with a draggable divider between panels (the price-over-indicator financial shape), the resizable multi-panel recipe works it end to end on these same primitives.
Props
<ChartContainer> — layout-relevant
| Prop | Type | Default | Purpose |
|---|---|---|---|
width | number | 'auto' | 'auto' | Overall chart width in CSS px, or 'auto' to measure and fill. Real pixels, never "100%". |
height | number | 'auto' | — | Manage vertical layout: rows flex into height minus the axis strip. Omitted = classic mode (rows declare pixels). |
rowGap | number | 0 | Vertical space between stacked rows in px (not under the axis). |
showAxis | boolean | true | Auto-render the shared x axis under the rows. false for a bare plot or to place your own <XAxis>. |
grid | boolean | true | Draw the vertical gridlines (a calendar-density hierarchical grid on a time axis). false for a clean backdrop. |
(range, theme, cursor, and the axis-kind props are covered under
Axes and Interaction.)
<ChartRow>
| Prop | Type | Default | Purpose |
|---|---|---|---|
height | number | — | Fixed row height in CSS px. Omit for flex sizing. |
flex | number | 1 | Share of the container's remaining height (needs <ChartContainer height>). A bare <ChartRow> is flex={1}. |
cursor | CursorMode | container | Cursor presentation for this row, overriding the container. |
children | ReactNode | — | <YAxis> (left) · <Layers> · <YAxis> (right), in order. |
Reading the resolved layout — useChartFrame()
Everything above is the container working out a layout. useChartFrame()
publishes the result, so your own DOM can line up with it: a per-slot header
table above the plot, a column summary strip below it, a card pinned over one
band, a colour ramp keyed to the plot's own scale.
function SlotHeader() {
const { plot, bands } = useChartFrame();
if (bands === null) return null; // not a category axis
return (
<div
style={{ position: 'relative', marginLeft: plot.x, width: plot.width }}
>
{bands.labels.map((label, i) => {
const b = bands.at(i)!;
return (
<div
key={label}
style={{ position: 'absolute', left: b.x0, width: b.x1 - b.x0 }}
>
{label}
</div>
);
})}
</div>
);
}
<ChartContainer width="auto">
<SlotHeader />
<ChartRow height={200}>…</ChartRow>
</ChartContainer>;
| Field | What it is |
|---|---|
plot.x / plot.width | The plot rect's x geometry, relative to the container's box |
gutters.left / .right | The reserved axis gutters (gutters.left === plot.x) |
xScale / xKind | The shared x→pixel scale object, and which kind resolved ('time'/'value'/'category') — neither is the <ChartContainer xScale> prop, which names a value axis's spacing |
bands | Ordinal slot geometry (count, pitch, labels, at(i)) — null off a category axis |
row | { topInset, height, yScales, axisSides } — null outside a <ChartRow> |
Three things to know:
- The x/y split is the library's own. The container owns one shared x
scale; rows own their y scales. So
plotcarries x, and y lives onrow. - Scope follows placement, exactly as
useChartLegenddoes. At the container level you get the x frame androw: null; inside a<ChartRow>you also get that row's y scales. No prop selects it. - Pixel origins differ by box.
plot.xis relative to the container;xScale(v)andbands.at(i)are relative to the plot (0 … plot.width), so addplot.xto place DOM chrome;row.topInsetis relative to the row.
Read the frame rather than recomputing it. Re-deriving the plot rect means mirroring the library's gutter arithmetic and band packing, and that copy is correct only until either changes — at which point your chrome slides out of alignment with no type error and no failing test.
Sharp edges
widthis pixels or'auto', never percent —"100%"is not a valid value; usewidth="auto"(or omit it) to fill a fluid box.- An
'auto'container paints nothing on the first layout pass — it has no width yet. Pass a number if you need paint on the very first render. 'auto'needs a parent with a definite width. A parent sized by its own content (a float, aninline-block, a gridautotrack, a flex child withoutmin-width: 0) measures 0, and the chart is the content that would have given it a width — so the chart stays blank with no error. Hiding a container withdisplay: noneis fine: it keeps its last width and stays mounted, so pan/zoom and selection survive a tab switch.Layersis mandatory — draw layers must be inside a<Layers>, not direct children of the row; it's the z-stack boundary.- Author order is layout order — left axes before
Layers, right axes after; withinLayers, children draw back-to-front.
See also
- Axes — y-axis binding, dual axes, the x-axis kinds.
- Responsive width · Resizable panels — the two layout recipes.
- Storybook:
Layout—SingleRow,MultiRow,DifferentHeights,RowGap,VaryingGutters,EstelaShaped, and the multi-axis variants. - Storybook:
Frame/useChartFrame—Default,WideGutter,DualAxis,PerSlotHeader,CappedBands,BandAlignEnd,InPlotOverlay,TopInset,MultiRow. - Storybook:
Layout/Auto width—Default,OmittedWidth,Fixed,ConstrainedParent,PaddedWrapper,FlexRow,ResizableBox.