Date pickers are among the least accessible form controls on the web: custom calendars that trap focus, month grids that cannot be navigated with arrow keys, screen readers announcing “button 14” with no month or year, and date-of-birth pickers that require dozens of clicks to reach 1978 — while typing the date would have taken two seconds.
The right date input depends on the date. Memorable dates (birthdays, passport expiry) are best typed; dates chosen relative to today (appointments, deliveries) benefit from seeing a calendar. This page, part of keyboard navigation patterns, compares the options, builds the robust default — three labelled text fields — and sets out the keyboard model a calendar enhancement must support.
Context and prerequisites
The options:
- Native
<input type="date">— keyboard accessible in current browsers (segments editable with arrow keys and typing), localised display, a built-in picker. Downsides: segment order and appearance vary by locale and browser, some screen readers announce segments awkwardly, styling is limited, and reaching distant years in the picker is slow. - Three text fields (day, month, year) — a pattern used by government design systems for memorable dates. Each field is a plain labelled input; typing is fast and predictable; errors can target the exact part.
- Single text field with a format hint — “DD/MM/YYYY”; simple but error-prone across locales.
- Custom calendar picker — useful for choosing near-future dates in context (availability, weekdays); must implement the ARIA grid pattern and never be the only way to enter a date.
The core pattern: three labelled fields with part-specific errors
<fieldset aria-describedby="dob-hint dob-error">
<legend>What is your date of birth?</legend>
<p id="dob-hint" class="hint">For example, 27 3 1990</p>
<p id="dob-error" class="error" hidden></p>
<div class="date-parts">
<div>
<label for="dob-day">Day</label>
<input id="dob-day" name="dob-day" inputmode="numeric" autocomplete="bday-day" size="2">
</div>
<div>
<label for="dob-month">Month</label>
<input id="dob-month" name="dob-month" inputmode="numeric" autocomplete="bday-month" size="2">
</div>
<div>
<label for="dob-year">Year</label>
<input id="dob-year" name="dob-year" inputmode="numeric" autocomplete="bday-year" size="4">
</div>
</div>
</fieldset>
type Part = "day" | "month" | "year";
export type DateCheck =
| { ok: true; iso: string }
| { ok: false; message: string; parts: Part[] }; // which parts to mark invalid
export function checkDateParts(day: string, month: string, year: string, label = "Date of birth"): DateCheck {
const d = day.trim(), m = month.trim(), y = year.trim();
const missing = (["day", "month", "year"] as Part[]).filter((p, i) => ![d, m, y][i]);
if (missing.length === 3) return { ok: false, message: `Enter your ${label.toLowerCase()}.`, parts: ["day", "month", "year"] };
if (missing.length) return { ok: false, message: `${label} must include a ${missing.join(" and ")}.`, parts: missing };
if (![d, m, y].every((s) => /^\d+$/.test(s))) return { ok: false, message: `${label} must be a real date, using numbers.`, parts: ["day", "month", "year"] };
if (y.length !== 4) return { ok: false, message: "Year must include 4 numbers.", parts: ["year"] };
const [dn, mn, yn] = [Number(d), Number(m), Number(y)];
if (mn < 1 || mn > 12) return { ok: false, message: `${label} must be a real date.`, parts: ["month"] };
const t = new Date(Date.UTC(yn, mn - 1, dn));
if (t.getUTCDate() !== dn || t.getUTCMonth() !== mn - 1) return { ok: false, message: `${label} must be a real date.`, parts: ["day"] };
const iso = `${y}-${String(mn).padStart(2, "0")}-${String(dn).padStart(2, "0")}`;
return { ok: true, iso };
}
Mark only the failing parts with aria-invalid, show the message on the group, and store the ISO calendar string — never a Date at midnight UTC, for the reasons in validating dates across time zones.
Step-by-step walkthrough
- Pick the input by the kind of date. Memorable dates get text fields; near-future choices can use the native control or a calendar enhancement.
- Wrap parts in a
fieldsetwith the question aslegend. Each part has its own visible label (“Day”, “Month”, “Year”); the group has the hint and error, as in accessible errors for radio and checkbox groups. - Use
inputmode="numeric", nottype="number". Numeric keyboards on mobile, without spinners, scroll-wheel changes or the empty-string problem. - Add
autocompletetokens.bday-day,bday-month,bday-year(orbdayfor a single field) let browsers fill birthdays. - Validate on blur of the group and on submit. Name the missing or wrong part in the message; mark only those parts invalid.
- Do not auto-advance between parts. Jumping focus after two digits surprises users who type “3” for March and breaks correction with Backspace. Let Tab move between parts.
Why a calendar should be an enhancement, not the input
A calendar picker is a composite widget — a dialog containing a grid of buttons with month navigation — and building one that works with keyboard, screen readers, zoom and touch is substantial work. Even done well, it is slower than typing for any date the user already knows. Treating the picker as an optional enhancement to a text input (a “Choose date” button next to the field, opening the calendar in a dialog, writing the chosen date back into the field) keeps typing as the primary path and makes the picker’s shortcomings non-blocking. The picker then only has to be good at what it is for: showing weekdays and availability.
Failure modes and edge cases
1. Calendar as the only input
If the text field is read-only and only the calendar can set it, users who cannot operate the grid cannot enter a date. Keep the field editable.
2. Grid cells announced without context
“14, button” tells a screen-reader user nothing. Each day cell needs an accessible name with the full date (“Tuesday 14 October 2026”) and aria-selected or aria-current="date" for selection and today.
3. Locale order confusion
“05/06/2026” is 5 June in the UK and May 6 in the US. Separate labelled parts avoid ambiguity; if a single text field is used, show the expected order in the hint and parse strictly by locale.
4. Native date input quirks
Some browsers treat a partially filled type="date" as empty (value === "", with validity.badInput). Report “Enter a complete date” rather than “Required” in that case, as noted in using the Constraint Validation API with custom form state.
5. Zoom and small screens
Calendar grids overflow at 200% zoom or 320px widths. The dialog must reflow (seven columns remain, but cells shrink or the grid scrolls within the dialog) without clipping days.
Verification checklist
Frequently Asked Questions
Is native type="date" accessible enough?
For near-future dates in current browsers, it is generally usable by keyboard and screen reader and needs no JavaScript. Test it with your audience’s browsers; its segment announcements and picker vary. For dates of birth, separate text fields remain easier.
Should the year field accept two digits?
Ask for four. Two-digit years are ambiguous across centuries, and the message “Year must include 4 numbers” is clear. Accepting and expanding “90” to “1990” guesses, and guesses about birth years are wrong for some users.
How should date ranges work by keyboard?
Two date inputs (start and end), each with its own optional calendar, and a range rule validated on the group, as in validating a date range: start before end. A single two-month range picker can be offered as an enhancement.