Time Range Selectors for Time Series
Permalink to "Time Range Selectors for Time Series"Almost every time-series chart in a data product has a range selector: 24 hours, 7 days, 30 days, quarter, year, custom. It is one of the most-used controls on any dashboard and frequently one of the least accessible: icon-like pills with no group label, a “Custom” option that opens a drag-only calendar, and charts that redraw silently. Built as a radio group plus date inputs with a readout, it becomes simple for everyone — and it is often a better accessibility solution than zoom and pan.
This guide belongs to interactive chart controls.
Spec reference
Permalink to "Spec reference"- Radio group semantics — presets are mutually exclusive:
fieldsetwithlegendand native radios, orrole="radiogroup". - SC 1.3.1 (A), SC 4.1.2 (A) — group label and checked state exposed.
- SC 3.3.2 Labels or Instructions (A) — custom start and end inputs labelled with formats.
- SC 4.1.3 Status Messages (AA) — the chart’s new range and headline after a change.
- SC 3.2.2 On Input (A) — changing the range updates content, which is expected; it must not move focus.
When to use — and when not to
Permalink to "When to use — and when not to"Use a range selector for any time series where users choose a window. Prefer it to zoom and pan for most audiences; it is clearer and easier to operate. Combine with zoom only for exploratory analysis tools.
Do not make the custom option a drag-to-select calendar only; provide date inputs. Do not auto-apply custom dates on every keystroke; apply when both are valid, or with an Apply button.
The misapplication: preset pills implemented as links that reload the page with no indication of which is active, plus a “Custom” calendar popup that requires dragging across days.
Annotated code example
Permalink to "Annotated code example"<fieldset class="range-presets">
<legend>Time range</legend> <!-- SC 1.3.1 -->
<label><input type="radio" name="range" value="24h"> Last 24 hours</label>
<label><input type="radio" name="range" value="7d"> Last 7 days</label>
<label><input type="radio" name="range" value="30d" checked> Last 30 days</label>
<label><input type="radio" name="range" value="custom"> Custom</label>
</fieldset>
<div id="custom-range" hidden>
<label for="cr-from">From</label><input id="cr-from" type="date" aria-describedby="cr-fmt">
<label for="cr-to">To</label><input id="cr-to" type="date" aria-describedby="cr-fmt">
<span id="cr-fmt">Format: YYYY-MM-DD</span>
<button type="button" id="cr-apply">Apply range</button>
</div>
<h3 id="chart-title">Orders <span id="range-readout">— last 30 days (3 Sep to 2 Oct)</span></h3>
<p id="range-status" role="status" class="visually-hidden"></p>
presets.addEventListener('change', (e) => {
if (e.target.value === 'custom') { customBox.hidden = false; return; } // reveal inputs; focus stays on radio
customBox.hidden = true;
applyRange(presetToDates(e.target.value), e.target.labels[0].textContent);
});
applyBtn.addEventListener('click', () => {
const from = new Date(fromInput.value), to = new Date(toInput.value);
if (!(from <= to)) { showError('Start date must be on or before end date.'); return; } // SC 3.3.1
applyRange({ from, to }, 'Custom range');
});
async function applyRange({ from, to }, label) {
const data = await load(from, to);
chart.update(data);
readout.textContent = `— ${label.toLowerCase()} (${fmt(from)} to ${fmt(to)})`;
history.replaceState(null, '', `?from=${iso(from)}&to=${iso(to)}`);
status.textContent = `${label}: ${data.total.toLocaleString()} orders, ${trendWords(data)}.`; // SC 4.1.3
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Tab into presets | “Time range, grouping, Last 30 days, radio button, checked, 3 of 4” | — |
| Arrow to Last 7 days | “Last 7 days, checked” then “Last 7 days: 2,900 orders, up 4% on the previous week.” | — |
| Choose Custom | Inputs appear; focus stays | Announce “Custom range fields shown” if they appear far from the radios |
| Apply invalid range | Error text; focus to the first invalid field | — |
| Apply valid range | Readout and status updated | — |
Integration context
Permalink to "Integration context"Custom date inputs follow the date picker grid keyboard pattern and accessible date range filters. For exploratory analysis, combine with keyboard accessible chart zoom and pan. Dashboards that apply one range to every widget follow dashboard filters that apply across widgets.
Gotchas
Permalink to "Gotchas"Arrow keys apply immediately. Native radios change selection on arrow keys, which triggers a data load per arrow press. Debounce the load (300–500 ms), or use a select or a manual Apply for expensive ranges.
Time zones. “Last 24 hours” and “today” depend on the zone; state it in the readout when users span zones.
Comparisons. “Compared with previous period” toggles should be separate checkboxes with their own labels, not hidden in the preset names.
Common mistakes
Permalink to "Common mistakes"- Presets as unlabelled pills or links.
- A drag-only custom calendar.
- Silent chart redraws.
- No range readout beyond axis ticks.
Framework notes
Permalink to "Framework notes"Most component libraries have a segmented control or toggle group; use one that renders radio semantics (React Aria RadioGroup, Angular Material mat-button-toggle-group — which exposes radio semantics when not multiple). Date range pickers vary; check that they allow typed input. In ECharts or Highcharts, update data with setOption or setData rather than recreating the chart, so focus inside the chart is not lost.
<!-- Angular Material: toggle group with radio semantics -->
<mat-button-toggle-group name="range" aria-label="Time range" [(value)]="range">
<mat-button-toggle value="7d">Last 7 days</mat-button-toggle>
<mat-button-toggle value="30d">Last 30 days</mat-button-toggle>
</mat-button-toggle-group>
Worked example
Permalink to "Worked example"An ops dashboard’s range pills (1h, 6h, 24h, 7d) were links that reloaded the page. Keyboard users lost their place after every change, and screen readers announced “link, 7d” with no selected state. The team rebuilt them as a radio group with full labels (“Last 7 days”), debounced loading for arrow-key changes, a readout in the chart heading, and a summary announcement (“Last 7 days: error rate 0.4%, down from 0.6%”). The custom range uses typed date inputs with a calendar button.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a TimeRangeSelector with standard presets, a custom range with typed inputs, a readout slot and an onChange that the chart and linked views consume. Standard presets across products help users build habits.
FAQ
Permalink to "FAQ"Radio group or select for presets?
Radios for up to six presets; a select for more. Both are accessible.
Should presets apply on arrow keys?
Native radio behaviour selects on arrows. Debounce loading or use a select to avoid repeated loads.
Should the summary announcement include a trend?
A short headline helps: total and change versus the previous period.
How should "Custom" show its fields?
Reveal them next to the presets without moving focus; a short status (“Custom range fields shown”) helps if they appear elsewhere.
Should the range be shared across charts?
On a dashboard, a global range with per-widget overrides is common. Make overrides visible in each widget’s heading.
What about relative versus absolute ranges in URLs?
Store relative presets as presets (?range=7d) so links stay “last 7 days”; store custom ranges as absolute dates.
Related
Permalink to "Related"- Keyboard accessible zoom and pan — finer exploration
- Accessible date range filters — custom ranges in filters
- Segmented controls for view density — radio-group segments