Skip to main content

Filling a responsive box

<ChartContainer> takes width as a required pixel number, not "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.

Drag the bottom-right corner of the chart below to resize it — the width prop updates live:

src/examples/responsive-width.tsx
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:

  1. useLayoutEffect, not useEffect. 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.
  2. Measure synchronously on mount, then let ResizeObserver take over. ResizeObserver's own first callback isn't guaranteed to fire immediately in every browser — relying on it alone can leave width stuck at 0. getBoundingClientRect() in the effect body gives you a real number up front.
  3. 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. ChartContainer itself 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 (see GalleryCard, 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. ResizeObserver already 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).
  • 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 plain resize: horizontal box. 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 to ChartContainer and 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 sets overflow: hidden. 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: the resize/padding/border box and the measured ref box 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 GalleryCard component (website/src/components/GalleryCard) — the site's own production use of this recipe, one per card in a responsive grid.