Pan, zoom & range selection
Three related mechanisms move the view range — the slice of the x axis a
chart shows. They compose but are independent: drag-to-select a range, wire
that into a controlled zoom, and/or enable free pan/zoom. All of them speak
the same neutral range contract: a [start, end] pair in axis units.
Chapter 6 teaches the select-to-zoom loop as a payoff; this page is the full
prop reference. See
Learn charts, chapter 6 for
the narrative and the Cursors/Region
Storybook group for the per-variant walk.
Range selection — cursor="region"
Setting cursor="region" and providing onRegionSelect makes the region
cursor draggable: the user drags across the plot and, on release, you get
the selected range once.
cursor="region"
cursorSequence?: Sequence | BoundedSequence; // bucket snapping (time axis only)
onRegionSelect?: (range: readonly [number, number]) => void; // [lo, hi] on release
onRegionSelectfires once, on release, with[lo, hi]— always ordered low-to-high regardless of drag direction. Providing it is what turns the region cursor draggable; it's a notification — the container never zooms itself in response.cursorSequencesnaps the shaded band to buckets (whole minutes, whole sessions) as the drag extends, and is time-axis only — a "minute" is meaningless on a value axis. With nocursorSequencethe region cursor is the degenerate free-drag case (pixel-precise, no snapping), which is what a value-axis selection uses.- Range selection works on a time or value x axis; it's excluded on a category axis.
The select-to-zoom loop
Zooming is onRegionSelect → your state → controlled range. The container
draws whatever range you give it; the selection callback proposes a new one;
you decide whether to apply it. A "reset" is just setting range back to the
full extent.
const [range, setRange] = useState(full);
<ChartContainer
range={range}
cursor="region"
onRegionSelect={(r) => setRange(r)}
>
{/* … */}
</ChartContainer>;
import { 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';
export default function LearnZoom() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
// singleHostSeries() always returns a non-empty, fixed-length series, so
// timeRange() is never undefined here.
const bounds = series.timeRange()!;
const fullRange: readonly [number, number] = [bounds.begin(), bounds.end()];
const [range, setRange] = useState<readonly [number, number]>(fullRange);
const zoomed = range[0] !== fullRange[0] || range[1] !== fullRange[1];
return (
<div>
<div style={{ marginBottom: 10 }}>
<button
onClick={() => setRange(fullRange)}
disabled={!zoomed}
style={{
padding: '4px 12px',
borderRadius: 6,
border: '1px solid var(--site-surface-border)',
background: 'transparent',
cursor: zoomed ? 'pointer' : 'default',
opacity: zoomed ? 1 : 0.5,
fontSize: 13,
}}
>
← Reset zoom
</button>
<span style={{ marginLeft: 10, fontSize: 12, opacity: 0.7 }}>
drag on the chart to select a range
</span>
</div>
<ChartContainer
range={range}
width={560}
theme={theme}
cursor="region"
onRegionSelect={(r) => setRange(r)}
>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}
Because the container never zooms on its own, the same onRegionSelect can
drive anything a range means in your app — a filter, a fetch, a linked chart —
not only a zoom.
Free pan & zoom — panZoom
Separately from range-select, panZoom enables direct manipulation: drag
the plot to pan, wheel to zoom around the cursor. It's a three-way mode —
choose whether the plot captures pan, both, or neither:
panZoom | Gestures |
|---|---|
'none' / false (default) | Neither — the plot ignores drag and scroll. |
'pan' | Drag pans; the wheel is left alone (page still scrolls). |
'panZoom' / true | Drag pans and wheel zooms around the cursor. |
| Prop | Type | Default | Purpose |
|---|---|---|---|
panZoom | 'none' | 'pan' | 'panZoom' | boolean | 'none' | Which gestures the plot captures (table above). Boolean is the back-compat shorthand. |
axisPanZoom | 'none' | 'x' | 'y' | 'xy' | boolean | 'none' | Which axis strips take gestures — see Grabbing an axis. |
bounds | [number, number] | — | Outer extent — pan/zoom can't move the view outside it. Zoom-out ceiling; omit for no limit. |
minDuration | number | 1 | Zoom-in floor — the minimum visible duration in ms. |
onTimeRangeChange | ([start, end]) => void | — | Fires on pan/zoom with the new range. Wire back to range for controlled; omit for uncontrolled. |
range | [number, number] | TimeRange | — | The controlled view range (shared by all three mechanisms). |
Bounding the reachable window. bounds and minDuration fence the view
between an outer and an inner limit: bounds is the [min, max] the
view can never move outside — pan into an edge stops there (keeping its span),
and zoom-out is capped at the whole span — while minDuration is the narrowest
visible window (the zoom-in floor). Set both and a gesture can neither scroll
off into empty time nor zoom past a useful grain. Seed range within bounds.
(On a trading-time axis, bounds clamps in wall-clock ms — a sensible outer
limit; the per-session pan/zoom math already holds the trading span at each
calendar edge.)
Controlled vs. uncontrolled matters here. Uncontrolled (panZoom on, no
onTimeRangeChange), the container holds the view internally and seeds it from
range whenever it isn't actively holding one — but later range changes are
then ignored so they can't fight the user's pan. To drive the range externally
— or to follow a live sliding window — use controlled mode: handle
onTimeRangeChange and feed range yourself.
Grabbing an axis — axisPanZoom
The axis strips take gestures too, behind their own opt-in:
axisPanZoom | What becomes grabbable |
|---|---|
'none' / false (default) | Nothing — the strips are inert chrome. |
'x' | The <XAxis> strip. |
'y' | Every <YAxis> gutter. |
'xy' / true | Both. |
| Gesture | On the x strip | On a y gutter |
|---|---|---|
| Drag | Pans — exactly as dragging the plot | Zooms that one axis (up = in) |
| Wheel | Zooms about the pointer, as the plot | Zooms that one axis |
| Double-click | Back to the declared range | Releases that axis back to its fit |
<ChartContainer range={range} panZoom="panZoom" axisPanZoom="xy">
<YAxis id="price" format="$,.2f" />
…
<XAxis />
</ChartContainer>
The cursor stays an ordinary arrow at rest — a strip is chrome you also hover,
click and read — and becomes a directional (↕ / ↔) cursor only while a
gesture is actually running.
axisPanZoom is independent of panZoom, in both directions. An interactive
plot does not hand its axes gestures nobody asked for, and a chart can scale its
y axes with panZoom off entirely — which is the combination to reach for when
the plot's drag belongs to a selection sweep. The one thing they share is the
view: the x strip moves the same range the plot's pan does, and reports through
onTimeRangeChange the same way.
Auto or manual — reporting a y override
An auto-fitting y axis stops being auto the moment the user scales it, and most
UIs want to say so. onBoundsChange is that hand-off: it fires with the
[min, max] a gutter gesture reached, and with null when the axis is released
back to auto (double-click).
const [scale, setScale] = useState<[number, number] | null>(null); // null = auto
<YAxis
id="price"
{...(scale ? { min: scale[0], max: scale[1] } : {})}
onBoundsChange={setScale}
/>;
Providing it makes the axis controlled, exactly as onTimeRangeChange does
for the x view: the gesture only reports, and what the axis draws is the
min/max you feed back — so your panel can show them, badge the scale
"manual", and offer a toggle to auto (setScale(null), the same thing
double-clicking does). Omit it and the axis holds the zoom internally, which is
why a chart with no scale UI needs no wiring.
See Axes/PanZoom
for the whole loop in one story.
Three more things worth knowing:
- The x strip is the canvas gesture, moved to the axis — same maths, same
sign, same
bounds/minDurationfences — so there is one gesture vocabulary for the chart rather than one per surface. - A y gutter zooms only its own axis. That's the thing the plot can't express: its vertical drag scales every axis in the row by a single factor (the aspect lock), so it can't favour the left axis over the right. Grabbing a gutter names the axis; its sibling and every other row hold still.
- The x view is the container's, so panning or zooming the strip moves every row at once. A category x axis has no continuous domain and stays inert.
Sharp edges
- The container never zooms itself.
onRegionSelectandonTimeRangeChangeare notifications; a controlledrangeis the only thing that actually changes what's shown. This is deliberate — a range means different things in different apps. cursorSequenceis time-axis only. Bucket snapping needs a temporal grid; value-axis and category selections don't take one.- Uncontrolled
panZoomstops trackingrangeonce the user grabs it — if you need the view to follow live data, go controlled. boundsconstrains gestures, not the initialrange. Only pan/zoom output (and any range routed through the container) is clamped — arangeseeded outsideboundsshows as-is until the first gesture pulls it in. Seed it withinboundsyourself.boundswins overminDurationif they contradict. Aboundsspan narrower thanminDurationclamps to the (narrower) extent; keepminDuration ≤ boundswidth.- Mouse-only — pan is drag, zoom is wheel; no keyboard or touch gestures yet.
- The x strip's double-click reset is a no-op when controlled. It returns
the view to the declared
range, and underonTimeRangeChangeyourrangeis the panned view — there's no declared home left to go back to. Hold your own home range and reset it fromonMouseEventif you want one. (A y gutter's reset always works: it just drops that axis's own zoom.) onTimeRangeChangecovers the x view only — including the x strip's pan and wheel, like any other view change. A y gutter's zoom reports through that axis's ownonBoundsChange.
See also
- Learn charts, chapter 6 — the select-to-zoom loop taught end to end.
- Cursors & readouts — the other six cursor
modes (the readouts, as opposed to
region's selection). - Sweeps & multi-select — the other drag
that selects.
regiongives you a time range; a<MultiSelector>sweep gives you the marks inside one. Worth reading together, becauseregionSelectModifier="shift"claims the shift chord that a selection handler might otherwise want. - Storybook:
Cursors/Region—DragToSelect,Freeform,Sessions,ValueAxisSelect,PanAndSelect. - Storybook:
Cursors/Scenarios/Pan Zoom,Pan Only,Bounded— the threepanZoommodes and thebounds+minDurationfence.