People need to focus on a brief event and explore the surrounding time period.
Updated September 10, 2026
Problem
People can see a brief event on a chart but need to inspect the surrounding period without typing approximate times into a picker.
Solution
Let people select a time range directly on the chart and move to adjacent periods. Keep the shared time-range control and related panels synchronized.
Provide a clear way to zoom out or return to a previous range. Support keyboard and touch alternatives to precise dragging.
Fetch finer data when it is available for the new range. If the source cannot provide more detail, say so rather than making stretched points look like new measurements. Distinguish zooming from gestures used to filter or annotate.
Use when
People investigate events that are short relative to the current time window.
Don’t use when
The range is fixed by the report’s meaning—a month-end view that can be zoomed produces a chart whose title no longer describes it. Also skip it on a wallboard, where there is no pointer and an accidental zoom persists until someone notices.
Trade-offs
Zoom gestures can conflict with selection, annotation, or page scrolling. Very narrow ranges may exceed the source’s available detail. Saved or shared zoom states also need clear time context.
Checklist
Does dragging on one panel change the range for the whole page?
Does the time-range picker update to reflect a zoom, and vice versa?
Is there a one-action zoom out, and a history of previous ranges?
Does the query re-run at a finer interval, or is coarse data being stretched?
What is the finest range this data supports, and what happens past it?
How does drag-to-zoom coexist with any other drag gesture on the same chart?
Does the zoomed range go into the URL?
Is an accidental zoom easy to notice and easy to undo?
On touch, what is the gesture, and does it fight the page scroll?
Does a zoomed range persist into a saved view, and should it?
Compare
Grafana treats a drag on any panel as setting the dashboard’s time range, so zoom and the picker are one piece of state, and the zoomed window lands in the URL where it can be shared. Netdata goes further and synchronises pan and zoom across every chart on the page continuously, which at per-second resolution turns dozens of charts into one scrubbable instrument. Honeycomb folds the gesture into the query: narrowing the window is editing what you asked for, so there is no separate zoom state that can drift away from the result. Datadog keeps the drag for range selection and hangs actions off the selection, so the same gesture that zooms can also export or annotate the window.
Related
Time-range picker controls the same period through explicit inputs. Cross-filter changes the selected records. Hover detail helps locate a point of interest.
Time series supplies the chart. Annotation can show events near the selected interval.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
Drag to select a window, and a context strip underneath keeping the full range visible. Without the strip a reader who has zoomed four times has no idea where they are.
shadcn
npx shadcn@latest add button
Tokens
--card--muted-foreground--border--chart-1
14:02 to 14:12
Last 7 days
ZoomableChart.tsxDrag reports a range and owns nothing; the page holds the shared range, re-queries, and passes the previous one back for the return button. A context strip marks the window inside the full range.
import { useState, type PointerEvent } from "react";import { Button } from "@/components/ui/button";/** * The range is not this component's to own. Drag draws a window and the * chart reports it; the page holds the shared range, re-runs the query at a * finer interval, and hands back `data` for the new window. That is what * keeps every panel on the page at the same range and stops a zoom from * stretching coarse points it already had. * * The context strip underneath keeps the full range visible, so a reader who * has zoomed four times can still see where they are. */export type Range = { from: number; to: number; label?: string };export type Sample = { t: number; value: number };const hhmm = (t: number) => new Date(t).toISOString().slice(11, 16);/** "7d", "6h", "10m": the size of a window, for the back button. */export const span = ({ from, to }: Range) => { const m = Math.round((to - from) / 60_000); return m >= 1440 ? `${Math.round(m / 1440)}d` : m >= 60 ? `${Math.round(m / 60)}h` : `${m}m`;};const title = (r: Range) => r.label ?? `${hhmm(r.from)} to ${hhmm(r.to)}`;function linePath(data: Sample[], range: Range, w: number, h: number) { if (data.length < 2) return ""; const max = Math.max(...data.map((d) => d.value)); const x = (t: number) => ((t - range.from) / (range.to - range.from)) * w; const y = (v: number) => h - 4 - (v / max) * (h - 12); return data.map((d, i) => `${i ? "L" : "M"}${x(d.t).toFixed(1)} ${y(d.value).toFixed(1)}`).join(" ");}export function ZoomableChart({ range, data, onRangeChange, previous, onBack, context, width = 300, height = 100 }: { range: Range; /** Samples for `range`, at the interval the query chose for it. */ data: Sample[]; onRangeChange: (range: Range) => void; /** The range before this one. Getting back has to be one action. */ previous?: Range; onBack?: () => void; /** The full range and its samples, drawn as a strip with the current window marked. */ context?: { range: Range; data: Sample[] }; width?: number; height?: number;}) { const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null); const plotH = height - 14; const px = (e: PointerEvent<SVGSVGElement>) => { const box = e.currentTarget.getBoundingClientRect(); return Math.max(0, Math.min(width, ((e.clientX - box.left) / box.width) * width)); }; const release = () => { if (!drag) return; const [a, b] = [drag.x0, drag.x1].sort((m, n) => m - n); setDrag(null); if (b - a < 4) return; const at = (x: number) => range.from + (x / width) * (range.to - range.from); onRangeChange({ from: at(a), to: at(b) }); }; return ( <div> <div className="flex items-baseline justify-between border-b pb-2"> <span className="text-[11px] uppercase tracking-wide text-muted-foreground">{title(range)}</span> {previous && onBack && ( <Button variant="link" size="sm" className="h-auto p-0 text-[10px] text-chart-1" onClick={onBack}>back to {span(previous)}</Button> )} </div> <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="mt-2 block max-w-full cursor-crosshair select-none touch-none" role="img" aria-label={`${title(range)}, drag to zoom`} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); const x = px(e); setDrag({ x0: x, x1: x }); }} onPointerMove={(e) => drag && setDrag({ ...drag, x1: px(e) })} onPointerUp={release} > {drag && ( <rect x={Math.min(drag.x0, drag.x1)} y={0} width={Math.abs(drag.x1 - drag.x0)} height={plotH} className="fill-chart-1/20 stroke-chart-1" strokeWidth={1} /> )} <path d={linePath(data, range, width, plotH)} fill="none" className="stroke-chart-1" strokeWidth={2} /> <line x1={0} y1={plotH} x2={width} y2={plotH} className="stroke-border" /> <text x={0} y={height - 2} className="fill-muted-foreground text-[9px]">{hhmm(range.from)}</text> <text x={width} y={height - 2} textAnchor="end" className="fill-muted-foreground text-[9px]">{hhmm(range.to)}</text> </svg> {context && ( <div className="mt-2"> <p className="text-[10px] text-muted-foreground">{title(context.range)}</p> <svg width={width} height={28} viewBox={`0 0 ${width} 28`} className="block max-w-full" aria-hidden="true"> <rect x={((range.from - context.range.from) / (context.range.to - context.range.from)) * width} y={0} width={Math.max(2, ((range.to - range.from) / (context.range.to - context.range.from)) * width)} height={28} className="fill-chart-1/20 stroke-chart-1" strokeWidth={1} /> <path d={linePath(context.data, context.range, width, 28)} fill="none" className="stroke-muted-foreground" strokeWidth={1} /> </svg> </div> )} </div> );}
demo.tsxHow it is called: a ten-minute window out of seven days, with a range history and a stand-in query that returns finer samples for the smaller window.
import { useState } from "react";import { ZoomableChart, type Range, type Sample } from "./ZoomableChart";/** * Starts ten minutes deep into a seven-day chart. The page owns the range * and its history; each range gets its own query, so the ten-minute window * holds seventeen samples the seven-day one never fetched. Drag on the chart * to zoom further; back pops the history. */const NOW = Date.parse("2026-09-15T16:47:00Z");const WEEK: Range = { from: NOW - 7 * 86_400_000, to: NOW, label: "Last 7 days" };const SPIKE: Range = { from: Date.parse("2026-09-12T14:02:00Z"), to: Date.parse("2026-09-12T14:12:00Z") };/** What the query returned for each range: p95 in ms, evenly spaced across it. */const spread = (range: Range, values: number[]): Sample[] => values.map((value, i) => ({ t: range.from + (i / (values.length - 1)) * (range.to - range.from), value }));const WEEK_DATA = spread(WEEK, [180, 240, 200, 280, 240, 580, 260, 300, 260, 320]);const SPIKE_DATA = spread(SPIKE, [140, 180, 120, 240, 360, 560, 780, 620, 700, 480, 560, 340, 400, 260, 300, 220, 260]);/** Stands in for the query. A real page would fetch at range / maxDataPoints. */const query = (range: Range) => (range === WEEK ? WEEK_DATA : range === SPIKE ? SPIKE_DATA : SPIKE_DATA.filter((s) => s.t >= range.from && s.t <= range.to));export default function Demo() { const [history, setHistory] = useState<Range[]>([WEEK, SPIKE]); const range = history[history.length - 1]; return ( <div className="w-fit rounded-lg border bg-card p-4"> <ZoomableChart range={range} data={query(range)} onRangeChange={(next) => setHistory([...history, next])} previous={history[history.length - 2]} onBack={() => setHistory(history.slice(0, -1))} context={range === WEEK ? undefined : { range: WEEK, data: WEEK_DATA }} /> </div> );}
What it renders. Identical markup in both panes, with only the token values changing.
Screenshots are grouped by product and include the capture date.
Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.
Examples / Node graph panel September 10, 2026Grafana Play (signed out; no version string exposed)sparse · dark · desktop-web
Several service nodes extend beyond the panel’s right edge, so the full graph is not visible at once. Each node contains two values and a colored ring, with a legend explaining the measures. This adds detail but makes the map harder to scan for a single overall state. The zoom control helps reach the clipped nodes, though a clearer initial fit would make the relationships easier to inspect.
Service mapNodes represent services and lines represent their connections.