Resizable multi-panel layout
A full-height chart split into two stacked panels — a large top panel over a smaller bottom one — with a draggable divider between them, where the extra vertical space flows to the top panel. The canonical financial shape: a price chart on top, an indicator panel (RSI, volume, a vol model) below.
It's built entirely on @pond-ts/charts primitives — no library-specific
layout API. The mirror of this (slack to the bottom panel) ships as the
Layout/Multi-Panel → ResizableFill Storybook story.
Why no special support is needed
Three properties of ChartContainer make the splitter a plain consumer concern:
- It renders its
childreninside aflex-direction: columnbox — it does not inspect or clone children by type. ChartRowself-registers through React context (not by the container scanning its child array), so a non-row element dropped between rows is simply ignored by the gutter/scale machinery.ChartRow'sheightis a controllable prop, and each row re-derives its y-scales from it — so a live height change repaints with no explicit invalidate.
So you drop a splitter <div> between the two ChartRow elements and drive
the two height props from state. That's the whole trick.
The recipe
import { useLayoutEffect, useRef, useState } from 'react';
import type { SeriesSchema, TimeSeries } from 'pond-ts';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
type ChartTheme,
} from '@pond-ts/charts';
const AXIS_H = 22; // TimeAxis strip the container reserves below the rows (see notes)
const SPLITTER_H = 7;
const MIN_TOP = 140;
const MIN_BOTTOM = 80;
/**
* Measure the available box. The first read is synchronous
* (`getBoundingClientRect`) so it doesn't depend on `ResizeObserver`'s initial
* callback firing — its timing isn't guaranteed, and relying on it can leave a
* chart that never mounts. RO then keeps the size live on resize.
*/
function useMeasuredSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
const width = Math.round(r.width);
const height = Math.round(r.height);
setSize((p) =>
p.width === width && p.height === height ? p : { width, height },
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
export function PriceWithIndicator({
series,
theme,
}: {
series: TimeSeries<SeriesSchema>;
theme?: ChartTheme;
}) {
const [boxRef, { width, height }] = useMeasuredSize<HTMLDivElement>();
// The BOTTOM height is the source of truth (the user drags it); the TOP is
// the remainder — so slack always flows to the top panel.
const [bottomH, setBottomH] = useState(160);
const plot = Math.max(0, height - AXIS_H - SPLITTER_H);
const bottom = Math.min(
Math.max(bottomH, MIN_BOTTOM),
Math.max(MIN_BOTTOM, plot - MIN_TOP),
);
const top = Math.max(MIN_TOP, plot - bottom);
const dragY = useRef(0);
const dragging = useRef(false);
// Fill the parent: this box is height:100%, so it needs an ancestor with a
// defined height (or be a `flex: 1; min-height: 0` child of a column).
return (
<div ref={boxRef} style={{ height: '100%', minHeight: 0, minWidth: 0 }}>
{width > 0 && height > 0 && (
<ChartContainer width={width} theme={theme}>
<ChartRow height={top}>
<YAxis id="price" side="right" format="$,.2f" />
<Layers>
<LineChart
series={series}
column="price"
as="price"
axis="price"
/>
</Layers>
</ChartRow>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize panels"
style={{
height: SPLITTER_H,
cursor: 'row-resize',
touchAction: 'none',
}}
onPointerDown={(e) => {
dragging.current = true;
dragY.current = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
if (!dragging.current) return;
const dy = e.clientY - dragY.current;
dragY.current = e.clientY;
// drag DOWN (dy > 0) grows the top, shrinks the bottom
if (dy) setBottomH((h) => h - dy);
}}
onPointerUp={(e) => {
dragging.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
/>
<ChartRow height={bottom}>
<YAxis id="rsi" side="right" format=".0f" />
<Layers>
<LineChart series={series} column="rsi" as="rsi" axis="rsi" />
</Layers>
</ChartRow>
</ChartContainer>
)}
</div>
);
}
How the sizing works
plot= available height minus the reserved time-axis strip and the splitter.bottom= the dragged value, clamped so neither panel collapses ([MIN_BOTTOM, plot − MIN_TOP]).top = plot − bottom→ the top is the remainder. Grow the window andplotgrows whilebottomholds, so all the slack lands in the top panel.
To send slack to the bottom instead, make the top the state value and
compute bottom = plot − top — that's exactly the ResizableFill story.
Notes and gotchas
All consumer-side — none needs a library change:
AXIS_H = 22is a reserved-strip constant. It's theTimeAxistick strip the container places below the rows; the fill math needs it today. If you render the axis with a label or hide it (showAxis={false}), adjust. A container-levelheight/ fill mode would remove this bookkeeping.- Seed the size synchronously. Read
getBoundingClientRectin a layout effect, not solely from theResizeObservercallback — RO's initial fire isn't guaranteed, and a size stuck at0leaves the chart gated out. - Clamp both panels. A row height of
0produces a degenerate y-scale; keep a per-panel floor. - Pointer capture +
touchAction: 'none'. Capture onpointerdownso the drag keeps tracking when the cursor leaves the thin handle;touchAction: nonestops touch scrolling from stealing the gesture. - Full height needs a defined ancestor height.
height: 100%only fills if the parent chain has a height (or make the box aflex: 1; min-height: 0child of a flex column).
See also
- Charting — the chart layer overview.
- The
Layout/Multi-Panel → ResizableFillStorybook story — the slack-to-bottom mirror, browser-verified.