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.
Related
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.
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.
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.
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.
Profile / contribution graph September 11, 2026github.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.
Calendar heatmapWeeks form columns and weekdays form rows, with selected weekday labels to save space.
KPI tileThe yearly total provides context above the daily grid.
Demo / Annotations September 10, 2026Grafana 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.
AnnotationFrequent event markers compete with the request-rate line.
AnnotationThe event list provides readable timestamps and tags.
Filter barA tag filter is already applied to the annotations.
Time-range pickerThe selected twenty-four-hour range affects how densely markers appear.
Query / HEATMAP(duration_ms) September 10, 2026Honeycomb 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.
HeatmapThe linear scale leaves most of the chart above the dense lower band.
Dashboard builderThe query controls define which requests appear in the result.
Tabs as genresTabs provide different ways to investigate the same result.
Discover / filebeat logs September 10, 2026Elastic 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.