Zoom and pan on time 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. npx shadcn@latest add button Tokens this needs: --card, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // ZoomableChart.tsx ──────────────────────────────────────────────────────────────────────── 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) => { 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 (
{title(range)} {previous && onBack && ( )}
{ 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 && ( )} {hhmm(range.from)} {hhmm(range.to)} {context && (

{title(context.range)}

)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── 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([WEEK, SPIKE]); const range = history[history.length - 1]; return (
setHistory([...history, next])} previous={history[history.length - 2]} onBack={() => setHistory(history.slice(0, -1))} context={range === WEEK ? undefined : { range: WEEK, data: WEEK_DATA }} />
); }