Skip to main content

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:

src/examples/learn-02-two-row.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 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

PropTypeDefaultPurpose
widthnumber | 'auto''auto'Overall chart width in CSS px, or 'auto' to measure and fill. Real pixels, never "100%".
heightnumber | 'auto'Manage vertical layout: rows flex into height minus the axis strip. Omitted = classic mode (rows declare pixels).
rowGapnumber0Vertical space between stacked rows in px (not under the axis).
showAxisbooleantrueAuto-render the shared x axis under the rows. false for a bare plot or to place your own <XAxis>.
gridbooleantrueDraw 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>

PropTypeDefaultPurpose
heightnumberFixed row height in CSS px. Omit for flex sizing.
flexnumber1Share of the container's remaining height (needs <ChartContainer height>). A bare <ChartRow> is flex={1}.
cursorCursorModecontainerCursor presentation for this row, overriding the container.
childrenReactNode<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>;
FieldWhat it is
plot.x / plot.widthThe plot rect's x geometry, relative to the container's box
gutters.left / .rightThe reserved axis gutters (gutters.left === plot.x)
xScale / xKindThe 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
bandsOrdinal 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 plot carries x, and y lives on row.
  • Scope follows placement, exactly as useChartLegend does. At the container level you get the x frame and row: null; inside a <ChartRow> you also get that row's y scales. No prop selects it.
  • Pixel origins differ by box. plot.x is relative to the container; xScale(v) and bands.at(i) are relative to the plot (0 … plot.width), so add plot.x to place DOM chrome; row.topInset is 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

  • width is pixels or 'auto', never percent"100%" is not a valid value; use width="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, an inline-block, a grid auto track, a flex child without min-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 with display: none is fine: it keeps its last width and stays mounted, so pan/zoom and selection survive a tab switch.
  • Layers is 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; within Layers, 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: LayoutSingleRow, MultiRow, DifferentHeights, RowGap, VaryingGutters, EstelaShaped, and the multi-axis variants.
  • Storybook: Frame/useChartFrameDefault, WideGutter, DualAxis, PerSlotHeader, CappedBands, BandAlignEnd, InPlotOverlay, TopInset, MultiRow.
  • Storybook: Layout/Auto widthDefault, OmittedWidth, Fixed, ConstrainedParent, PaddedWrapper, FlexRow, ResizableBox.