Accessible Date Range Filters

Permalink to "Accessible Date Range Filters"

A date range filter narrows a table to records between two dates: invoices due this quarter, logs from the last hour, orders placed in March. The common implementations are a pair of unlabelled date pickers, a single “range picker” calendar that is operable only by dragging across days, or a text field that silently rejects any format but one. Each fails a different group of users.

This page builds a range filter from two labelled date inputs in a fieldset, with presets, validation and a result announcement. It belongs to accessible filtering & faceted search.

Spec reference

Permalink to "Spec reference"
  • <fieldset> with <legend> groups the two inputs and gives them a shared context: “Due date: From, To”. Screen readers announce the legend when focus enters the group.
  • <input type="date"> exposes a date field with segment-based keyboard editing in Chromium, Firefox and Safari; its value is always ISO YYYY-MM-DD regardless of display locale. min and max constrain it.
  • aria-invalid and aria-describedby tie a validation error to the input that has it.

Criteria: SC 1.3.1 (the relationship between the two inputs and the field being filtered), SC 3.3.1 Error Identification and SC 3.3.3 Error Suggestion (start after end), SC 3.3.2 Labels or Instructions (format for text inputs), SC 2.1.1 Keyboard (no drag-only range selection), and SC 4.1.3 for the result count.

Anatomy of a date range filter Mock of a date range filter laid out as a row: legend, From input, To input, preset control and Apply button, each numbered with its accessible treatment. Anatomy of a date range filterLegendFromToPresetsApplyDue da…12026-03-…22026-03-…This mont…3Apply1fieldset legend "Due date" — announcedwhen focus enters the group2Each input has its own visible label, Fromand To3Presets fill both inputs; the inputs remainthe source of truth
Two ordinary inputs in a named group — the range is conveyed by structure, not by a special widget.

When to use two inputs — and when a calendar

Permalink to "When to use two inputs — and when a calendar"

Use two date inputs as the base in every case. They are keyboard operable, announced correctly, easy to type into for users who know the dates, and they work with voice input (“click From, type March first”).

Add a calendar popup on top only if your design needs one, and build it as a supplement: the inputs remain editable, and the calendar is a dialog containing a grid of days that writes to them. A range calendar where users drag across days must have a keyboard equivalent — pick start, then pick end — or it fails SC 2.1.1 and SC 2.5.7 Dragging Movements.

The misapplication to name is a single text field expecting “01/03/2026 - 31/03/2026”. Users do not know the separator, the day–month order is ambiguous across locales, and a typo produces an error that points at the whole field.

Annotated code example

Permalink to "Annotated code example"
<form class="filter" id="due-filter">
  <!-- SC 1.3.1: the group names the field being filtered -->
  <fieldset>
    <legend>Due date</legend>
    <label for="due-from">From</label>
    <input type="date" id="due-from" name="from" min="2020-01-01"
           aria-describedby="due-from-err">
    <label for="due-to">To</label>
    <input type="date" id="due-to" name="to"
           aria-describedby="due-to-err">
    <!-- SC 3.3.1: error slots exist in advance, empty until needed -->
    <span id="due-from-err" class="error"></span>
    <span id="due-to-err" class="error"></span>

    <label for="due-preset">Preset range</label>
    <select id="due-preset">
      <option value="">Custom</option>
      <option value="this-month">This month</option>
      <option value="last-30">Last 30 days</option>
      <option value="this-quarter">This quarter</option>
    </select>
  </fieldset>
  <button type="submit">Apply date filter</button>
</form>
const from = document.getElementById('due-from');
const to = document.getElementById('due-to');

document.getElementById('due-preset').addEventListener('change', (e) => {
  const range = presetRange(e.target.value);     // { from: 'YYYY-MM-DD', to: '…' }
  if (range) { from.value = range.from; to.value = range.to; }
  // no auto-apply: presets change inputs; Apply changes the table (SC 3.2.2)
});

document.getElementById('due-filter').addEventListener('submit', async (e) => {
  e.preventDefault();
  clearErrors();
  if (from.value && to.value && from.value > to.value) {   // ISO strings compare correctly
    // SC 3.3.1 + 3.3.3: error on the input, with a fix
    setError(to, 'due-to-err', 'End date is before start date. Choose a date on or after ' + fmt(from.value) + '.');
    to.focus();
    return;
  }
  const n = await applyFilter({ from: from.value, to: to.value });
  // SC 4.1.3: one message naming the result and the range
  status(`${n} invoices due ${rangeText(from.value, to.value)}.`);
});

The Apply button is a deliberate choice. Filtering on every change of a date input fires as the user edits each segment — changing the month from 03 to 04 passes through intermediate states — producing several table reloads and several announcements. An explicit action gives one of each.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT-specific deviations
Tab into the group “Due date, grouping. From, date, …” VoiceOver reads the legend after the label
Up/Down Arrow in a segment New segment value: “April” Firefox reads the whole date; Chrome the segment
Choose preset “This month” then focus stays on the select Inputs update silently; their new values are read when visited
Apply with end before start Focus to To; “End date is before start date…” Description read after the field on all readers
Apply valid “18 invoices due 1 March to 31 March 2026.” Polite
Filtering by a preset, then refining Timeline of a keyboard user choosing the This month preset, adjusting the end date, applying the filter and hearing the result. Filtering by a preset, then refiningPreset"This month" fills both inputsTab to To"To, date, 31 March 2026"Down Arrowday changes to 15Applyone table reloadResult"9 invoices due 1 to 15 March."one filter interaction
Presets and manual edits share the same inputs, so refining a preset is just editing a field.

Integration context

Permalink to "Integration context"

The result message follows announcing filter result counts with aria-live, with the range included so users know what the count refers to. Clearing the range — a “Clear dates” button — should keep focus in the filter panel, as in clearing filters without losing keyboard focus.

Active filters should be summarised near the table (“Due 1–15 March · Status: overdue”) as removable chips, so users who land on the results can hear what is applied.

Range pickers compared Comparison of a drag-only calendar range picker against two labelled date inputs with optional presets. Range pickers compared✗ Drag-only range calendarRange made by dragging across daysNo typed entryOne widget, two values, one labelErrors point at the whole widget✓ Two inputs in a fieldsetTyped, arrowed or voice-dictatedEach input labelled From and ToPresets fill the inputsErrors point at the input that is wrong
The calendar can stay as a supplement — the inputs must be there either way.

Gotchas

Permalink to "Gotchas"

Time zones. “Today” at 23:30 in one time zone is tomorrow on the server. State the zone if it can surprise users, and filter using the user’s zone.

Open-ended ranges. Allow an empty From or To to mean “no limit”, and say so in the result: “invoices due after 1 March”.

Locale display versus value. Native date inputs display in the browser’s locale but always submit ISO. Do not parse the displayed text.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
What is the most accessible date range filter?

Two native date inputs labelled From and To inside a fieldset whose legend names the field, with an Apply button. Presets and a calendar can be added on top, but the labelled inputs should always be there.

Should a date range filter apply automatically?

Not on every change. Editing a date segment by segment passes through intermediate values, so auto-apply causes several reloads and announcements. Apply on a button, or after the user leaves the group.

How should a start date after the end date be reported?

As an error on the end input, with aria-invalid and a description that suggests a fix, and focus moved to that input. Do not silently swap the dates — users may have mistyped one of them.

Is a calendar range picker accessible?

Only if it can be operated without dragging and alongside typed inputs. Build it as a dialog with a keyboard-navigable grid of days that writes to the From and To inputs.

Permalink to "Related"

← Back to Accessible Filtering & Faceted Search