Skip to main content

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
  • onRegionSelect fires 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.
  • cursorSequence snaps 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 no cursorSequence the 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>;
src/examples/learn-06-zoom.tsx
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:

panZoomGestures
'none' / false (default)Neither — the plot ignores drag and scroll.
'pan'Drag pans; the wheel is left alone (page still scrolls).
'panZoom' / trueDrag pans and wheel zooms around the cursor.
PropTypeDefaultPurpose
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.
minDurationnumber1Zoom-in floor — the minimum visible duration in ms.
onTimeRangeChange([start, end]) => voidFires on pan/zoom with the new range. Wire back to range for controlled; omit for uncontrolled.
range[number, number] | TimeRangeThe 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:

axisPanZoomWhat becomes grabbable
'none' / false (default)Nothing — the strips are inert chrome.
'x'The <XAxis> strip.
'y'Every <YAxis> gutter.
'xy' / trueBoth.
GestureOn the x stripOn a y gutter
DragPans — exactly as dragging the plotZooms that one axis (up = in)
WheelZooms about the pointer, as the plotZooms that one axis
Double-clickBack to the declared rangeReleases 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 / minDuration fences — 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. onRegionSelect and onTimeRangeChange are notifications; a controlled range is the only thing that actually changes what's shown. This is deliberate — a range means different things in different apps.
  • cursorSequence is time-axis only. Bucket snapping needs a temporal grid; value-axis and category selections don't take one.
  • Uncontrolled panZoom stops tracking range once the user grabs it — if you need the view to follow live data, go controlled.
  • bounds constrains gestures, not the initial range. Only pan/zoom output (and any range routed through the container) is clamped — a range seeded outside bounds shows as-is until the first gesture pulls it in. Seed it within bounds yourself.
  • bounds wins over minDuration if they contradict. A bounds span narrower than minDuration clamps to the (narrower) extent; keep minDuration ≤ bounds width.
  • 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 under onTimeRangeChange your range is the panned view — there's no declared home left to go back to. Hold your own home range and reset it from onMouseEvent if you want one. (A y gutter's reset always works: it just drops that axis's own zoom.)
  • onTimeRangeChange covers 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 own onBoundsChange.

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. region gives you a time range; a <MultiSelector> sweep gives you the marks inside one. Worth reading together, because regionSelectModifier="shift" claims the shift chord that a selection handler might otherwise want.
  • Storybook: Cursors/RegionDragToSelect, Freeform, Sessions, ValueAxisSelect, PanAndSelect.
  • Storybook: Cursors/Scenarios/Pan Zoom, Pan Only, Bounded — the three panZoom modes and the bounds + minDuration fence.