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.
Which date input for which date For a date of birth, use three text fields because the date is memorable and typing is fastest. For a passport or card expiry, use month and year text fields. For an appointment in the next few weeks, use a native date input or a text field enhanced with a calendar picker, because seeing weekdays helps. For a date range such as a holiday, use two date fields with a calendar enhancement. For an approximate historical date, use text fields that allow partial dates. Date Input Why date of birth three text fields memorable; typing is fastest card / passport expiry month + year fields printed on the card appointment in coming weeks native date or calendar enhancement weekdays matter holiday range two dates + calendar see span and availability approximate past date text fields, partial allowed users may not know the day

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

  1. 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.
  2. Wrap parts in a fieldset with the question as legend. 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.
  3. Use inputmode="numeric", not type="number". Numeric keyboards on mobile, without spinners, scroll-wheel changes or the empty-string problem.
  4. Add autocomplete tokens. bday-day, bday-month, bday-year (or bday for a single field) let browsers fill birthdays.
  5. Validate on blur of the group and on submit. Name the missing or wrong part in the message; mark only those parts invalid.
  6. 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.

Keyboard model for a calendar grid enhancement Arrow keys move focus by one day left or right and by one week up or down. Home and End move to the start and end of the week. Page Up and Page Down move to the previous and next month, and with Shift to the previous and next year. Enter or Space selects the focused date and closes the dialog. Escape closes the dialog without changing the date and returns focus to the button that opened it. Key Moves focus / does ← / → previous / next day ↑ / ↓ same day previous / next week Home / End first / last day of the week Page Up / Page Down previous / next month (Shift: year) Enter / Space select date, close, write to field Escape close without change; focus back to button

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.

A calendar enhancement done right The primary input is an editable text field with a visible hint, next to a Choose date button. Activating the button opens a modal dialog containing a month heading and a grid of day cells, each named with the full date and navigable with the grid keyboard model. Selecting a date writes it into the text field, closes the dialog and returns focus to the button, and validation runs as if the user had typed it. Text field + button Editable; typing always works. "Choose date" button. Dialog with grid Cells named with full dates. Arrow, Page, Home/End keys. Write back, return focus Value in the field. Focus back on the button.

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.


Related

← Keyboard Navigation Patterns