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 ISOYYYY-MM-DDregardless of display locale.minandmaxconstrain it.aria-invalidandaria-describedbytie 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.
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 |
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.
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.
Related
Permalink to "Related"- Announcing filter result counts — the message after applying
- Clearing filters without losing focus — resetting the range
- Date editors inside grid cells — the same input inside a grid