Filling a responsive box
width="auto"<ChartContainer width="auto"> — or omitting width entirely — does
everything on this page for you: it renders a plain full-width box, measures
it, and mounts the chart at that pixel width, re-rendering as the box resizes.
<ChartContainer range={series.timeRange()} width="auto">
…
</ChartContainer>
Reach for the hand-rolled version below when you need the measured width for
something besides the chart — a sibling element sized to match, a
breakpoint decision, a width-dependent data query. If you only want the chart
to fill its box, use 'auto' and skip the rest of this page.
Note that inside the chart, aligning your own chrome to the plot no longer
needs the container's width at all —
useChartFrame()
publishes the resolved plot rect, gutters and scales directly.
<ChartContainer> takes width as a pixel number or 'auto', never
"100%" — the canvas renderer needs real pixels to lay out ticks and
slots before it draws. Filling a box that itself resizes (a flex column, a
CSS Grid cell, a browser window) is one ResizeObserver away — and that
observer is what 'auto' runs internally.
Drag the bottom-right corner of the chart below to resize it — the width prop updates live:
import { useLayoutEffect, useRef, useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';
/** Measure a box's content width via `ResizeObserver`. The first read is
* synchronous (`getBoundingClientRect`) so it doesn't depend on RO's
* initial callback firing — its timing isn't guaranteed, and relying on
* it can leave a chart that never mounts. RO then keeps it live. */
function useMeasuredWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () =>
setWidth(Math.round(el.getBoundingClientRect().width));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
export default function ResponsiveWidth() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
const [boxRef, width] = useMeasuredWidth<HTMLDivElement>();
return (
<div
style={{
resize: 'horizontal',
overflow: 'hidden',
minWidth: 240,
maxWidth: '100%',
border: '1px dashed var(--site-surface-border)',
borderRadius: 8,
padding: 8,
}}
>
{/* The measured box is a plain, unpadded child of the resize
handle — measuring the padded box itself would hand
ChartContainer a width wider than the space it's actually
rendered in (padding + border), silently clipped by the
resize box's own overflow: hidden. */}
<div ref={boxRef}>
{width > 0 && (
<ChartContainer
range={series.timeRange()}
width={width}
theme={theme}
>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
)}
</div>
</div>
);
}
The recipe
import { useLayoutEffect, useRef, useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
/**
* Measure a box's content width via `ResizeObserver`. The first read is
* synchronous (`getBoundingClientRect`) so it doesn't depend on RO's
* initial callback firing — its timing isn't guaranteed, and relying on
* it can leave a chart that never mounts. RO then keeps it live.
*/
function useMeasuredWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () =>
setWidth(Math.round(el.getBoundingClientRect().width));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
export function ResponsiveChart({ series, theme }) {
const [boxRef, width] = useMeasuredWidth<HTMLDivElement>();
return (
<div ref={boxRef} style={{ width: '100%' }}>
{width > 0 && (
<ChartContainer range={series.timeRange()} width={width} theme={theme}>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
)}
</div>
);
}
Three pieces:
useLayoutEffect, notuseEffect. A layout effect runs before the browser paints, so the first real width lands before the user sees a flash of the zero-width fallback.- Measure synchronously on mount, then let
ResizeObservertake over.ResizeObserver's own first callback isn't guaranteed to fire immediately in every browser — relying on it alone can leavewidthstuck at0.getBoundingClientRect()in the effect body gives you a real number up front. - Gate the render on
width > 0.<ChartContainer width={0}>is a degenerate chart, not an empty one — don't mount it until you have a real measurement.
Notes and gotchas
- The ref goes on a plain box, not the container.
ChartContaineritself renders a fixed-width canvas; measure an ancestor<div>and pass its width down, the same way every live embed on this site does it (seeGalleryCard, which uses this exact pattern for its card grid, and Resizable multi-panel layout, which extends it with a draggable splitter between two rows). - Debounce only if you have a real reason to.
ResizeObserveralready batches — most consumers don't need to add their own debounce/throttle on top. Add one only if profiling shows the redraw cost matters (many rows, many series, a very hot resize loop). - The parent needs a definite width — this bites
width="auto"too. Awidth: 100%box inside a parent whose own width comes from its 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 the parent a width. That deadlocks: the chart stays blank, with no error. Give the parent a width, aflexbasis, ormin-width: 0. - A resizable parent needs a resizable parent.
width: '100%'on the measured box only does anything if something upstream actually constrains it — a flex/grid cell, a resizable panel, or (as in the demo above) a plainresize: horizontalbox. Measuring a box with no external constraint just measures its own content, which doesn't change. - Never measure a box you've also styled with padding or a border.
getBoundingClientRect()returns the full border box; hand that number straight toChartContainerand the chart renders at the padded box's outer width while only having the inner (content) width available, overflowing by exactly the padding + border — silently clipped if the box also setsoverflow: hidden.width="auto"cannot hit this — the box it measures is one the library owns and never styles, so a padded wrapper of your own is always safe. It is the sharpest reason to prefer'auto'over the hand-rolled version. If you want a bordered/padded frame around the chart (as the demo above does, for the dashed resize outline), put that styling on an outer wrapper and measure a plain, unstyled inner box instead — the demo's own source does this: theresize/padding/borderbox and the measuredrefbox are two nested<div>s, not one.
See also
- Resizable multi-panel layout — this same pattern,
extended with a draggable splitter between two
ChartRows. - The Gallery's
GalleryCardcomponent (website/src/components/GalleryCard) — the site's own production use of this recipe, one per card in a responsive grid.