Skip to content
KONIGI Patterns

Dashboards / Interaction / Zoom and pan on time

‹ 8 of 8

Zoom and pan on time

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.

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.

Zoom and pan on time anatomy A ten-minute region dragged out of a seven-day chart, and the result. The zoomed chart has fetched finer data rather than stretching the coarse points it already had, so it contains detail the first chart never held. Ten minutes out of seven days Last 7 days 3 14:02 to 14:12 back to 7d 4 1 2 1 DRAW THE RANGE Zoom and the range picker are one control with two interfaces, and they have to stay in sync in both directions. 2 THE QUERY CHANGES TOO Going from six hours to ninety seconds has to fetch finer data. A panel that zooms the axis alone magnifies an average. 3 THE WHOLE PAGE MOVES Zooming one panel and leaving the others at six hours makes a dashboard whose panels disagree, which is worse than not zooming. 4 GETTING BACK IS FREE Exploration means overshooting. Without a history of previous ranges, every wrong drag costs a retype and people stop drilling in. Zoom changes the window on the same population. Filter changes the population.
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
14:0214: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.

Examples

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.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Node graph panel September 10, 2026 Grafana 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.