Skip to content
KONIGI Patterns

Dashboards / Labels and context / Time-range picker

‹ 6 of 6

Time-range picker

People need to choose the period a dashboard shows and see that choice clearly.

Updated September 10, 2026

Problem

People need to change the period shown by a dashboard and understand the current window without opening a control.

Solution

Show the selected range prominently. Offer useful relative ranges for current monitoring and fixed start and end times for historical investigation. Where needed, support a fixed start with an end that continues to now.

Display the timezone and keep related panels in sync. Mark any panel-specific range clearly.

When sharing, explain whether the link preserves the exact period or moves relative to when it is opened. Make it easy to pin an investigation to fixed times. Show when a wider range changes the data’s detail or aggregation.

Use when

The period being viewed affects how the data should be interpreted.

Don’t use when

The view answers a fixed question over a fixed window. A month-end report with a range picker invites someone to change it and get an answer that no longer matches the title above it.

Trade-offs

A global range may not fit every panel, but exceptions weaken easy comparison. Relative links show different data later. Wide ranges can hide short events through aggregation, and long lists of presets slow selection.

Checklist

  • Is the current range readable without opening the control?
  • Are the common windows one click away, and are they the windows this audience actually uses?
  • Can someone express “since 09:40 today” without doing arithmetic?
  • Does the range go into the URL, and does a copied link mean the same thing tomorrow?
  • Which timezone is this, and does the control say so?
  • Does changing the range change the aggregation interval, and can the viewer tell?
  • Do all panels share this range, and if any override it, is that visible on the panel?
  • What happens to a relative range on a wallboard nobody reloads?
  • Does zooming on a chart update this control, and does the control update the chart?
  • Is there a way back to the previous range after an accidental change?

Compare

Grafana carries the fullest version of the control—relative, absolute, semi-relative, typed shorthand, timezone, per-panel shift—and pays for it with a picker that takes a moment to learn and a URL that can disagree with itself. Honeycomb folds the range into the query rather than floating it above the page, so the window is part of what you asked rather than ambient state that can drift out from under a saved result. Sentry scopes time to the thing being examined, so an issue carries first-seen and last-seen and the range is mostly a filter on a list rather than an axis on a chart. Netdata treats dragging on any chart as the range control itself and syncs every other chart on the page to match, which removes the picker as a separate thing to find.

Zoom and pan changes the range directly on a chart. Compare periods adds a reference window. Freshness indicator shows the age of the latest data.

Saved view preserves a useful range. Template variable carries other shared dashboard selections.

Time-range picker anatomy Three kinds of range drawn on one axis: a relative window that slides with now, an absolute one pinned to two timestamps, and a semi-relative one with a fixed start and now as its end. Below, the same relative range pasted into a writeup and read a day later. Three kinds of range, one axis now RELATIVE last 6h 1 ABSOLUTE 14:05 to 17:20 2 SEMI-RELATIVE since the deploy 3 08:00 13:00 18:00 1 WHAT PEOPLE USE Last 15 minutes, last 6 hours, last 7 days. The most common action on the page, so it gets the least friction. 2 WHAT PEOPLE SHARE A pinned start and end. The only kind that means the same thing tomorrow. 3 WHAT GETS SKIPPED A fixed start with now as the end. That's the shape of "since the incident started", which is most of what anyone wants mid-investigation. Paste from=now-6h into a writeup and by tomorrow it points at nothing in particular.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The control that decides what every panel on the page means. Relative ranges are the common case and the absolute one has to state its timezone, because a shared link crossing timezones silently changes the data.

shadcn
npx shadcn@latest add button popover input
npm
lucide-react
Tokens
--foreground--popover--popover-foreground--secondary--secondary-foreground--muted--muted-foreground--border--input--chart-1--status-warn

TimeRangePicker.tsxThe panel is the three kinds: relative with a typed box, absolute with both ends, since with the events. An axis draws the choice against now.

import { useState } from "react";
import { Clock, Link } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";

/**
 * Three kinds of range, and the type says which one this is. A relative
 * range slides with now, an absolute one is pinned at both ends, and a
 * semi-relative one is pinned at the start with now as its end, which is the
 * shape of "since the deploy" and the one pickers leave out.
 */
export type Range =
  | { kind: "relative"; last: string }
  | { kind: "absolute"; from: Date; to: Date }
  | { kind: "since"; from: Date; label?: string };

export type Event = { label: string; at: Date };

const QUICK = ["15m", "1h", "6h", "24h", "7d"];
const UNIT: Record<string, number> = { m: 60_000, h: 3_600_000, d: 86_400_000 };
const back = (last: string, now: Date) => new Date(now.getTime() - parseInt(last) * UNIT[last.slice(-1)]);

const hhmm = (d: Date, timeZone: string) =>
  new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", timeZone }).format(d);

/** Both ends as instants, whichever kind it is. */
export function resolve(r: Range, now: Date): { from: Date; to: Date } {
  if (r.kind === "relative") return { from: back(r.last, now), to: now };
  if (r.kind === "absolute") return { from: r.from, to: r.to };
  return { from: r.from, to: now };
}

/** The range as the trigger reads it, without opening anything. */
export function label(r: Range, tz: string) {
  if (r.kind === "relative") return `last ${r.last}`;
  if (r.kind === "absolute") return `${hhmm(r.from, tz)} to ${hhmm(r.to, tz)}`;
  return r.label ? `since ${r.label}` : `since ${hhmm(r.from, tz)}`;
}

/** What goes in a shared link. Both ends are pinned first: from=now-6h means
 *  a different six hours to whoever opens it tomorrow. */
export function toParams(r: Range, now: Date, tz: string) {
  const { from, to } = resolve(r, now);
  return new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), tz }).toString();
}

/** The range drawn on an axis that ends at now, so the reader sees which end
 *  slides. The axis is the last `spanMs` before now. */
function Axis({ range, now, tz, spanMs }: { range: Range; now: Date; tz: string; spanMs: number }) {
  const { from, to } = resolve(range, now);
  const x = (d: Date) => Math.max(0, Math.min(100, 100 - ((now.getTime() - d.getTime()) / spanMs) * 100));
  const ticks = [0.75, 0.5, 0.25].map((f) => new Date(now.getTime() - spanMs * f));
  const slides = range.kind !== "absolute";
  return (
    <div className="px-2 pb-1 pt-2">
      <div className="relative h-5">
        <div className="absolute inset-x-0 top-2 h-px bg-border" />
        <div
          className={`absolute top-0 h-4 rounded-sm ${slides ? "bg-chart-1/60" : "bg-muted-foreground/40"}`}
          style={{ left: `${x(from)}%`, width: `${Math.max(1, x(to) - x(from))}%` }}
          title={`${hhmm(from, tz)} to ${to.getTime() === now.getTime() ? "now" : hhmm(to, tz)}`}
        />
        <div className="absolute right-0 top-[-2px] h-6 w-px bg-status-warn" />
      </div>
      <div className="relative h-3 text-[9px] tabular-nums text-muted-foreground">
        {ticks.map((t) => <span key={+t} className="absolute -translate-x-1/2" style={{ left: `${x(t)}%` }}>{hhmm(t, tz)}</span>)}
        <span className="absolute right-0 text-status-warn">now</span>
      </div>
    </div>
  );
}

export function TimeRangePicker({ value, onChange, now, timezone, events, onCopyLink, defaultOpen = false }: {
  value: Range;
  onChange: (range: Range) => void;
  /** The clock a relative range is measured from. Passed in, so a wallboard
   *  that never reloads can still be told what time it is. */
  now: Date;
  timezone: string;
  /** Deploys, incidents: the starts people want without doing arithmetic. */
  events: Event[];
  onCopyLink: (params: string) => void;
  defaultOpen?: boolean;
}) {
  const [open, setOpen] = useState(defaultOpen);
  const [typed, setTyped] = useState("");
  const abs = resolve(value, now);
  const [from, setFrom] = useState(hhmm(abs.from, timezone));
  const [to, setTo] = useState(hhmm(abs.to, timezone));
  const pick = (r: Range) => { onChange(r); setOpen(false); };
  const is = (r: Range) => label(r, timezone) === label(value, timezone);
  const row = (on: boolean) =>
    `flex w-full items-center justify-between rounded px-2 py-1 text-left text-xs hover:bg-muted ${on ? "font-medium text-chart-1" : ""}`;

  // "13h" is a range. Grafana accepts it, and so should anything else.
  const submitTyped = () => {
    if (/^\d+[mhd]$/.test(typed)) pick({ kind: "relative", last: typed });
  };
  // Two clock times on today's date. A real picker takes a date too; the
  // shape is what matters here: both ends pinned, nothing slides.
  const submitAbsolute = () => {
    const day = now.toISOString().slice(0, 10);
    const f = new Date(`${day}T${from}:00Z`), t = new Date(`${day}T${to}:00Z`);
    if (!isNaN(+f) && !isNaN(+t) && f < t) pick({ kind: "absolute", from: f, to: t });
  };

  return (
    <Popover open={open} onOpenChange={setOpen} modal={false}>
      <PopoverTrigger asChild>
        <Button variant="outline" size="sm" className="gap-2 text-xs">
          <Clock className="size-3.5" />
          {label(value, timezone)}
          {value.kind !== "absolute" && <span className="text-muted-foreground">to now</span>}
          <span className="text-muted-foreground">{timezone}</span>
        </Button>
      </PopoverTrigger>

      <PopoverContent align="start" className="w-[340px] p-2 text-xs" onOpenAutoFocus={(e) => e.preventDefault()}>
        <Axis range={value} now={now} tz={timezone} spanMs={12 * UNIT.h} />

        <p className="mt-1 px-2 py-1 text-muted-foreground">Relative · slides with now</p>
        <div className="flex flex-wrap items-center gap-1 px-2 pb-2">
          {QUICK.map((last) => (
            <Button key={last} variant={is({ kind: "relative", last }) ? "secondary" : "ghost"} size="sm"
              className="h-6 px-2 text-xs" onClick={() => pick({ kind: "relative", last })}>
              last {last}
            </Button>
          ))}
          <Input value={typed} onChange={(e) => setTyped(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitTyped()}
            placeholder="13h" aria-label="last, typed" className="h-6 w-14 px-1.5 text-xs" />
        </div>

        <p className="px-2 py-1 text-muted-foreground">Absolute · pinned at both ends</p>
        <div className="flex items-center gap-1.5 px-2 pb-2">
          <Input value={from} onChange={(e) => setFrom(e.target.value)} aria-label="from" className="h-6 w-16 px-1.5 text-xs tabular-nums" />
          <span className="text-muted-foreground">to</span>
          <Input value={to} onChange={(e) => setTo(e.target.value)} aria-label="to" className="h-6 w-16 px-1.5 text-xs tabular-nums" />
          <Button variant="secondary" size="sm" className="h-6 px-2 text-xs" onClick={submitAbsolute}>apply</Button>
        </div>

        <p className="px-2 py-1 text-muted-foreground">Since · pinned start, now as the end</p>
        {events.map((e) => (
          <button key={e.label} type="button" className={row(is({ kind: "since", from: e.at, label: e.label }))}
            aria-current={is({ kind: "since", from: e.at, label: e.label }) || undefined}
            onClick={() => pick({ kind: "since", from: e.at, label: e.label })}>
            <span>since {e.label}</span>
            <span className="tabular-nums text-muted-foreground">{hhmm(e.at, timezone)}</span>
          </button>
        ))}

        <div className="mt-2 flex items-center justify-between border-t pt-2">
          <span className="text-muted-foreground">{timezone}</span>
          <Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs" onClick={() => onCopyLink(toParams(value, now, timezone))}>
            <Link className="size-3" /> copy link, pinned
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}

demo.tsxHow it is called: open on since the deploy, with the deploy and the incident as starts.

import { useState } from "react";
import { TimeRangePicker, type Range } from "./TimeRangePicker";

/**
 * Mid-investigation: the range is "since the deploy", a pinned start with
 * now as its end. The axis at the top of the panel shows it against now.
 * Pick "last 6h" or type 14:05 to 17:20 to see the other two kinds drawn.
 * The clock is fixed so relative ranges resolve the same on the server.
 */
const NOW = new Date("2026-09-15T17:40:00Z");
const at = (hhmm: string) => new Date(`2026-09-15T${hhmm}:00Z`);

const DEPLOY = { label: "the deploy", at: at("12:20") };
const INCIDENT = { label: "the incident opened", at: at("14:05") };

export default function Demo() {
  const [range, setRange] = useState<Range>({ kind: "since", from: DEPLOY.at, label: DEPLOY.label });
  const [link, setLink] = useState<string | null>(null);

  return (
    <div className="min-h-[440px]">
      <TimeRangePicker
        value={range}
        onChange={setRange}
        now={NOW}
        timezone="UTC"
        events={[DEPLOY, INCIDENT]}
        onCopyLink={setLink}
        defaultOpen
      />
      {link && <p className="mt-2 break-all font-mono text-[10px] text-muted-foreground">?{link}</p>}
    </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.

GitHub

The contribution calendar shows daily activity using a small set of color levels.

Profile / contribution graph September 11, 2026 github.com, signed out (public profile) medium · light · desktop-web
GitHub arranges daily contributions by week and weekday, with a yearly total above the grid. The five color levels make the calendar compact but limit precise comparisons between days. In this active profile, many cells have similar shades, making variation harder to see. The weekday rows still provide a useful way to look for recurring gaps or patterns.
Show 3 more examples Hide the rest

Grafana

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

Demo / Annotations September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Frequent red event markers make the request-rate line difficult to follow across this twenty-four-hour view. The tag filter is already active, so filtering alone has not reduced the crowding enough. The event list below is easier to read because it separates timestamps and tags into individual entries. This example shows why event density matters as much as recording the events.
  • Annotation Frequent event markers compete with the request-rate line.
  • Annotation The event list provides readable timestamps and tags.
  • Filter bar A tag filter is already applied to the annotations.
  • Time-range picker The selected twenty-four-hour range affects how densely markers appear.

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
This sandbox heatmap uses a linear vertical scale. Most requests sit near the bottom, while a separate group of slower requests extends upward later in the period. The view exposes variation that a single summary value would hide, though a different scale could give the lower band more room. The footer reports query duration and rows examined. A cookie banner and onboarding dialog were removed for the capture; the dashboard itself was left intact.

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Discover / filebeat logs September 10, 2026 Elastic demo environment (guest session; no version string exposed) dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.