# Date pickers that can't be typed in or used by keyboard

Source: https://easeweb.dev/learn/date-picker
Topics: Custom inputs > Dates and times can be typed, and the calendar works with a keyboard (WCAG 2.1.1, 4.1.2, 3.3.2)
The fix: Either use a native date input with a label, which people can type into part by part and which the browser fills from its own calendar, or keep a text field, state its format in visible text tied to it with aria-describedby, and add a Choose date button that opens a dialog with a calendar grid: the arrow keys, Home, End, Page Up and Page Down move between days, Enter picks one, Escape closes, and the chosen day and today carry aria-selected and aria-current.
Test with: keyboard, screen reader, zoom, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html https://www.w3.org/WAI/WCAG22/Techniques/general/G89 https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/ https://www.w3.org/TR/wai-aria-1.2/#aria-current https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date https://design-system.service.gov.uk/components/date-input/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's checkout asks for a delivery date. The field shows "Choose a date", and a click opens a calendar under it for November 2026, with today, Monday the 2nd, in bold. A click on Thursday the 12th fills the field with 12/11/2026. With a keyboard, Tab reaches the field and stops there: typing puts nothing in it, Enter and the arrow keys open nothing, and the next Tab moves on to the next control. There is no way to give a date, so the order can't be placed. A screen reader announces "Delivery date" as a read-only text field, which tells its user the field can't be typed in and offers nothing else. If the calendar is opened with the mouse, a screen reader reads each day as just its number, "12", with nothing to say it can be chosen, which day is chosen or which one is today.

The shop's quick repair is to let the field take typing. Now someone who writes dates month first types 11/12/2026 for 12 November, and the shop, which reads every date day first, books the delivery for Friday 11 December, a month late, without a word. Someone else types 12 Nov 2026, which the shop can't read as a date at all. The label says "Delivery date" and nothing more, so there was no way to know which form it wanted. The calendar is unchanged, and still fails as before.

## Why it fails

Success Criterion 2.1.1 asks that everything a mouse can do can be done with a keyboard. The field has `readonly`, so the browser lets it take focus but refuses typing, and the only way to fill it is the calendar. The calendar opens on a `click` event, which a text field doesn't fire for any key, and its days are `div` elements, picked by a click handler on the box that holds them, and a div can't take focus. The browser allows all three, and none is invalid on its own; together they leave the date to the mouse.

Success Criterion 4.1.2 asks that a control's name, role and states are available to assistive technology. On screen, the days are controls. In the code, they are divs: no role, a name that is only the day number, and the chosen day and today marked by `selected` and `today` classes that change only how they look. A browser passes roles and states on from the markup, and a class carries neither, so a screen reader has nothing to say about them.

Success Criterion 3.3.2 asks for labels or instructions where content needs input, and its Understanding page gives, as an example, a date field with text that states the format. A date can be written day first, month first or year first, with slashes, dashes or words, and the second Before reads only one of these without saying which. "Delivery date" names the field but doesn't say how to fill it in, so 11/12/2026 is taken for a different day from the one meant, and nothing on the page shows that it happened.

## Who is affected

A delivery or booking date is often required before payment, so a field that can't be filled without a mouse stops the order. On a phone, a tap still opens the calendar, so touch testing rarely shows anything wrong, which is part of why this field survives so long.

## What automation and AI miss

A scanner such as axe finds a text field with a label and passes it: `readonly` is valid, and no rule says a date must be typeable. The calendar isn't shown until a click, and its days are divs with no role or ARIA, so there is nothing for a rule to fault. Whether "Delivery date" says enough about the format is not a question a rule asks. Date picker libraries can produce the first Before's read-only field by default: flatpickr, for one, makes its field read-only unless its allowInput option is set. AI assistants asked for "a date picker" can write the calendar from divs, or add `role="grid"` to it and leave the days with no focus and no `aria-selected`. Only typing, opening the calendar from the keyboard and listening shows whether it works.

## Before: a read-only field and a calendar only a click opens

```html
<label for="delivery">Delivery date</label>
<input id="delivery" name="delivery" type="text" readonly placeholder="Choose a date">
<div class="calendar" hidden>
  <div class="cal-head">
    <span class="prev">‹</span>
    <span class="cal-month">November 2026</span>
    <span class="next">›</span>
  </div>
  <div class="cal-week">
    <span>Su</span>
    <span>Mo</span>
    <span>Tu</span>
    <span>We</span>
    <span>Th</span>
    <span>Fr</span>
    <span>Sa</span>
  </div>
  <div class="cal-days"></div>
</div>
```

```js
const field = document.getElementById('delivery');
const calendar = document.querySelector('.calendar');
const days = calendar.querySelector('.cal-days');
const today = new Date();
let shown = today;

// The shop reads every date as day/month/year.
function readDate(text) {
  const [day, month, year] = text.split('/').map(Number);
  return new Date(year, month - 1, day);
}
const same = (a, b) => a.toDateString() === b.toDateString();
const daysIn = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();

function draw() {
  calendar.querySelector('.cal-month').textContent =
    shown.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
  const chosen = readDate(field.value);
  days.replaceChildren();
  for (let n = 1; n <= daysIn(shown); n++) {
    const day = new Date(shown.getFullYear(), shown.getMonth(), n);
    const div = document.createElement('div');
    div.className = 'day';
    div.textContent = n;
    if (n === 1) div.style.gridColumnStart = day.getDay() + 1;
    div.classList.toggle('selected', same(day, chosen));
    div.classList.toggle('today', same(day, today));
    days.append(div);
  }
}
field.addEventListener('click', () => {
  draw();
  calendar.hidden = false;
});
for (const [button, step] of [['.prev', -1], ['.next', 1]]) {
  calendar.querySelector(button).addEventListener('click', () => {
    shown = new Date(shown.getFullYear(), shown.getMonth() + step, 1);
    draw();
  });
}
days.addEventListener('click', (event) => {
  const div = event.target.closest('.day');
  if (!div) return;
  const day = new Date(shown.getFullYear(), shown.getMonth(), Number(div.textContent));
  field.value = day.toLocaleDateString('en-GB');
  calendar.hidden = true;
});
```

```css
.cal-days {
  display: grid;
  grid-template-columns: repeat(7, 2.25rem);
}
.cal-days .today {
  font-weight: bold;
}
.cal-days .selected {
  background: var(--accent);
  color: white;
}
```

The field refuses typing, the calendar opens only on a click, and its days are divs no key can reach, so a keyboard can't set a date at all. The days have no role, and the chosen day and today are classes, so a screen reader reads only numbers.

## Before: a field that takes typing but states no format

```html
<label for="delivery">Delivery date</label>
<input id="delivery" name="delivery" type="text" placeholder="Choose a date">
<div class="calendar" hidden>
  <div class="cal-head">
    <span class="prev">‹</span>
    <span class="cal-month">November 2026</span>
    <span class="next">›</span>
  </div>
  <div class="cal-week">
    <span>Su</span>
    <span>Mo</span>
    <span>Tu</span>
    <span>We</span>
    <span>Th</span>
    <span>Fr</span>
    <span>Sa</span>
  </div>
  <div class="cal-days"></div>
</div>
```

```js
const field = document.getElementById('delivery');
const calendar = document.querySelector('.calendar');
const days = calendar.querySelector('.cal-days');
const today = new Date();
let shown = today;

// The shop reads every date as day/month/year.
function readDate(text) {
  const [day, month, year] = text.split('/').map(Number);
  return new Date(year, month - 1, day);
}
const same = (a, b) => a.toDateString() === b.toDateString();
const daysIn = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();

function draw() {
  calendar.querySelector('.cal-month').textContent =
    shown.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
  const chosen = readDate(field.value);
  days.replaceChildren();
  for (let n = 1; n <= daysIn(shown); n++) {
    const day = new Date(shown.getFullYear(), shown.getMonth(), n);
    const div = document.createElement('div');
    div.className = 'day';
    div.textContent = n;
    if (n === 1) div.style.gridColumnStart = day.getDay() + 1;
    div.classList.toggle('selected', same(day, chosen));
    div.classList.toggle('today', same(day, today));
    days.append(div);
  }
}
field.addEventListener('click', () => {
  draw();
  calendar.hidden = false;
});
for (const [button, step] of [['.prev', -1], ['.next', 1]]) {
  calendar.querySelector(button).addEventListener('click', () => {
    shown = new Date(shown.getFullYear(), shown.getMonth() + step, 1);
    draw();
  });
}
days.addEventListener('click', (event) => {
  const div = event.target.closest('.day');
  if (!div) return;
  const day = new Date(shown.getFullYear(), shown.getMonth(), Number(div.textContent));
  field.value = day.toLocaleDateString('en-GB');
  calendar.hidden = true;
});
```

```css
.cal-days {
  display: grid;
  grid-template-columns: repeat(7, 2.25rem);
}
.cal-days .today {
  font-weight: bold;
}
.cal-days .selected {
  background: var(--accent);
  color: white;
}
```

This is the same field with `readonly` taken off. A keyboard can now type a date, but nothing says the shop reads it day first, so 11/12/2026 becomes 11 December, and 12 Nov 2026 isn't read at all. The calendar is untouched, so its days still fail 2.1.1 and 4.1.2 as in the first Before.

## After: a native date input

```html
<label for="delivery">Delivery date</label>
<input id="delivery" name="delivery" type="date">
```

The browser lets people type each part of the date in turn, gives them its own calendar to pick from, and tells a screen reader the field's name and role and the date in it. It also takes the order of the parts from its own language settings and, in most desktop browsers, shows it in the empty field, such as dd/mm/yyyy, so the format is stated without any words of yours, and the value sent is always 2026-11-12, whatever the order on screen. On a phone, a tap opens the system's own date picker. The script and the styles are gone: there is no calendar left to maintain.

This passes as far as each browser's own control does. Its calendar can't be styled. It can limit the range with `min` and `max`, but it can't shut out single days, such as Sundays or a sold-out day, so those have to be refused after the date is chosen. Browser and screen reader pairs read its parts differently, so test the ones you support. The order follows the browser, not the page, so a Japanese page in a browser set to US English shows month first; don't write a format of your own beside a native date field, since it may not match.

## After: a typed field with its format, and a calendar dialog

```html
<label for="delivery">Delivery date</label>
<p id="delivery-hint">Day, month, year, for example 27/03/2027</p>
<input id="delivery" name="delivery" type="text" aria-describedby="delivery-hint">
<button type="button" id="delivery-button" aria-haspopup="dialog" aria-label="Choose date">
  <svg aria-hidden="true">…</svg>
</button>
<dialog class="calendar" aria-label="Choose date">
  <div class="cal-head">
    <button type="button" class="prev" aria-label="Previous month">‹</button>
    <h2 class="cal-month" id="cal-month" aria-live="polite">November 2026</h2>
    <button type="button" class="next" aria-label="Next month">›</button>
  </div>
  <table role="grid" aria-labelledby="cal-month">
    <thead>
      <tr>
        <th scope="col">Su</th>
        <th scope="col">Mo</th>
        <th scope="col">Tu</th>
        <th scope="col">We</th>
        <th scope="col">Th</th>
        <th scope="col">Fr</th>
        <th scope="col">Sa</th>
      </tr>
    </thead>
    <tbody class="cal-days"></tbody>
  </table>
  <button type="button" class="cancel">Cancel</button>
</dialog>
```

```js
const field = document.getElementById('delivery');
const button = document.getElementById('delivery-button');
const calendar = document.querySelector('.calendar');
const days = calendar.querySelector('.cal-days');
const today = new Date();
let shown = today;

// The shop reads every date as day/month/year, as the hint says.
function readDate(text) {
  const [day, month, year] = text.split('/').map(Number);
  return new Date(year, month - 1, day);
}
const same = (a, b) => a.toDateString() === b.toDateString();
const daysIn = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
const full = (date) => date.toLocaleDateString('en-GB', { dateStyle: 'full' });

function draw() {
  calendar.querySelector('.cal-month').textContent =
    shown.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
  const chosen = readDate(field.value);
  days.replaceChildren();
  let row;
  for (let n = 1; n <= daysIn(shown); n++) {
    const day = new Date(shown.getFullYear(), shown.getMonth(), n);
    if (n === 1 || day.getDay() === 0) row = days.insertRow();
    if (n === 1) for (let gap = 0; gap < day.getDay(); gap++) row.insertCell();
    const cell = row.insertCell();
    cell.textContent = n;
    // Each day is named with its full date, and only the shown day is a Tab stop.
    cell.setAttribute('aria-label', full(day));
    cell.tabIndex = same(day, shown) ? 0 : -1;
    if (same(day, chosen)) cell.setAttribute('aria-selected', 'true');
    if (same(day, today)) cell.setAttribute('aria-current', 'date');
  }
}
function moveTo(date) {
  shown = date;
  draw();
  days.querySelector('[tabindex="0"]').focus();
}
function pick(date) {
  field.value = date.toLocaleDateString('en-GB');
  button.setAttribute('aria-label', `Change date, ${full(date)}`);
  calendar.close();
}
button.addEventListener('click', () => {
  const chosen = readDate(field.value);
  calendar.showModal();
  moveTo(Number.isNaN(chosen.getTime()) ? today : chosen);
});
// Escape, Cancel and a pick all close the dialog, and focus goes back to the button.
calendar.addEventListener('close', () => button.focus());
calendar.querySelector('.cancel').addEventListener('click', () => calendar.close());
// Tab stays in the calendar: from Cancel on to Previous month, and back with Shift.
calendar.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const first = calendar.querySelector('.prev');
  const last = calendar.querySelector('.cancel');
  if (event.shiftKey && document.activeElement === first) last.focus();
  else if (!event.shiftKey && document.activeElement === last) first.focus();
  else return;
  event.preventDefault();
});
for (const [name, step] of [['.prev', -1], ['.next', 1]]) {
  calendar.querySelector(name).addEventListener('click', () => {
    shown = new Date(shown.getFullYear(), shown.getMonth() + step, 1);
    draw();
  });
}
days.addEventListener('click', (event) => {
  const cell = event.target.closest('td[aria-label]');
  if (cell) pick(new Date(shown.getFullYear(), shown.getMonth(), Number(cell.textContent)));
});
days.addEventListener('keydown', (event) => {
  const next = new Date(shown);
  if (event.key === 'ArrowRight') next.setDate(next.getDate() + 1);
  else if (event.key === 'ArrowLeft') next.setDate(next.getDate() - 1);
  else if (event.key === 'ArrowDown') next.setDate(next.getDate() + 7);
  else if (event.key === 'ArrowUp') next.setDate(next.getDate() - 7);
  else if (event.key === 'Home') next.setDate(next.getDate() - next.getDay());
  else if (event.key === 'End') next.setDate(next.getDate() + 6 - next.getDay());
  else if (event.key === 'PageDown' || event.key === 'PageUp') {
    // A month, or a year with Shift, on the same day where the month has one.
    const months = (event.key === 'PageDown' ? 1 : -1) * (event.shiftKey ? 12 : 1);
    next.setDate(1);
    next.setMonth(next.getMonth() + months);
    next.setDate(Math.min(shown.getDate(), daysIn(next)));
  } else if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    return pick(shown);
  } else return;
  event.preventDefault();
  moveTo(next);
});
```

```css
.cal-days td {
  width: 2.25rem;
  height: 2.25rem;
  text-align: center;
}
/* Today and the chosen day are drawn from the states a screen reader reads, not from classes. */
.cal-days [aria-current="date"] {
  font-weight: bold;
}
.cal-days [aria-selected="true"] {
  background: var(--accent);
  color: white;
  outline: 2px solid transparent;
}
.cal-days td:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}
@media (forced-colors: active) {
  .cal-days [aria-selected="true"] {
    outline-color: Highlight;
  }
}
```

The field takes typing again, and the line under its label says which order to write the date in, with an example; `aria-describedby` ties that line to the field, so a screen reader reads it after the label. The calendar is a help, not the only way in. It opens from a button that Tab reaches, and `aria-haspopup="dialog"` tells screen readers that support it what the button opens. The dialog is opened with `showModal()`, so the page behind it is inert while it is open and Escape closes it. Tab moves through Previous month, Next month, the grid and Cancel. After the last of them, a modal dialog lets Tab go on to the browser's own controls, such as the address bar, which to someone on a keyboard looks like focus lost, so the script wraps Tab from Cancel back to Previous month, and Shift + Tab the other way, as APG's date picker dialog does. Focus moves to the chosen day, or to today, and the grid is one Tab stop: the arrow keys move by a day or a week, Home and End to the start and end of the week, Page Up and Page Down by a month, and with Shift by a year. Each day is read with its full date, the chosen one as selected and today through `aria-current="date"`. Enter, Space or a click puts the day in the field, and focus returns to the button, now named "Change date" with the date, so a screen reader user hears what was chosen.

This passes only as far as your script does. Every key above has to work, the Tab stop has to follow the focus, and the dialog has to fit beside the field at 200% zoom and on a phone. APG, whose date picker dialog this is based on, warns that support may have gaps in some browser and assistive technology pairs, especially on touch devices. Test it in each browser and screen reader you support.

## Which option to use

Both pass 2.1.1, 4.1.2 and 3.3.2. The native date input needs no script: every browser gives its own keys, its own calendar and its own announcements, and phones get the system picker. Choose it when any day in a range will do, and the browser's look for the calendar is acceptable. Build the typed field and the dialog when some days must be shut out, such as Sundays or days with no slots, or when the calendar has to show more than dates, such as prices, and accept that its keys, states and touch behaviour are yours to test and maintain. Either way, the date can be typed.

## Implementation decisions

A typed field with its format and no calendar at all is a fix on its own; a calendar is only worth adding when it works as the dialog above does, since the second Before shows that an old one left beside the field still fails. Keep the field working if the calendar script fails to load. If a library draws your date picker, switch on its typing option, such as flatpickr's allowInput, and check its calendar with the keyboard and a screen reader as in the steps below before trusting its documentation.

State the format in visible text, not only in a placeholder, which disappears as soon as someone types; see [form fields with missing or mismatched labels](/learn/form-labels). Read forgivingly too: a date with or without a leading zero, or with dashes or dots instead of slashes, can still be read in the stated order. Showing the date back in words beside the field, such as "Thursday, 12 November 2026", lets people catch a misread day before the order is sent.

Days that can't be chosen, such as Sundays, still belong in the grid, so the weeks line up: mark them with `aria-disabled="true"`, let the arrow keys pass over them, and say why in text near the field. A native date input can't shut out single days: `min` and `max` set only the first and last day.

Other dates and times need other fields:

- A date people already know, such as a date of birth, is quicker to type than to find in a calendar that opens on today's month. Use separate day, month and year fields in a fieldset with a hint, as the GOV.UK date input does, or a single typed field with its format.
- A time can use a native `<input type="time">`, or a typed field that states its format, such as "24-hour, for example 14:30". A few fixed delivery slots are radio buttons.
- A scrolling wheel of hours and minutes built from divs fails as the first Before does: give it the keys and roles of a native control, or use one.

A calendar that opens and takes focus as soon as the field receives focus is a different failure, covered in [dropdowns and fields that change the page on their own](/learn/context-changes).

## Verify the fix

1. Inspect the field: it is an input with `type="date"` and a label, or a text input with no `readonly`, `aria-describedby` pointing at a visible format, and a button with `aria-haspopup="dialog"` opening a named dialog whose grid has one cell with `tabindex="0"`, `aria-selected="true"` on the chosen day and `aria-current="date"` on today.
2. Put the mouse aside, Tab to the field and type the date in the stated format, or part by part in a native field, then Tab on: the field holds that date, and the page reads it as that date.
3. Tab to Choose date and press Enter: focus is on the chosen day or today. Move with the arrow keys, Home, End, Page Up and Page Down, pick a day with Enter, then open it again, Tab past Cancel and Shift + Tab past Previous month (focus stays in the calendar), and press Escape: each time focus returns to the button.
4. With a screen reader, Tab to the field and through the calendar: you hear the label and the format, each day's full date as you reach it, "selected" on the chosen day and "current date" on today, and the date in the button's name after picking.
5. At 200% zoom and in forced colors, check the calendar opens fully in view beside the field, and the chosen day, today and the focus ring are all visible.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared date field or calendar component changes.

## Limits

This is a starting pattern for a single date. It doesn't cover choosing a range, such as check-in and check-out dates, time zones, or calendars other than the Gregorian one, such as Japanese era years. The snippets leave out checking the date against the days the shop delivers. Test the complete journey, with your real dates, in the browsers and assistive technology you support, on a phone as well as a desktop.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
