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: fieldset with legend and native radios, or role="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.
Choosing a time range Flow: the user picks a preset radio and the chart updates and announces; or picks Custom, which reveals start and end inputs; applying them updates the chart, the readout and the URL. Choosing a time rangePreset radios24 h, 7 d, 30 d, Quarter, Yeararrows to chooseChart updatesreadout and announcement"Last 30 days: 12,400 orders"Customreveals start and end inputstyped dates with format hintsApplyvalidates and updatessame readout and announcementURL?range=30d or ?from&toshareable and restorable

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.

Pills as links versus a radio group Comparison: link pills reload the page and give no selected state; a radio group exposes the selection, keeps focus and updates in place. Pills as links versus a radio group✗ Preset pills as links"Link, 30d" ×5No selected state for ATPage reloads, focus lostCustom via drag calendar✓ Radio group"Time range, grouping, Last 30 days, radiobutton, checked, 3 of 6"Checked state exposedUpdates in place; focus staysCustom via labelled date inputs

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 —
Range control options Matrix of range controls — preset radios, custom date inputs, zoom and pan, brushing — against simplicity, precision, keyboard access and suitability for general audiences. Range control optionsSimplicityPrecisionKeyboardGeneral audiencesPreset radiosHighFixed windowsYesBestCustom date inputsHighExactYesGoodZoom and panMediumApproximateWith controlsAnalystsBrushingLowApproximateWith alternativesAnalysts
Presets plus custom inputs cover most needs.

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.

Permalink to "Related"

← Back to Interactive Chart Controls