// pattern
Accessible Date Picker Pattern
A fully accessible date picker with a text input fallback for direct entry and a calendar popover for visual selection. Supports full keyboard navigation, screen reader announcements, and WCAG 2.2 AA compliance.
// 01 · live demo
Live Demo
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|
Click the calendar icon or press Enter on the toggle button to open the date picker. Use arrow keys to navigate between dates, Enter or Space to select, and Escape to close.
// 02 · the code
The Code
<div class="datepicker-wrapper">
<label for="date-input" id="date-label">Select a date</label>
<div class="datepicker-input-group">
<input
type="text"
id="date-input"
placeholder="MM/DD/YYYY"
autocomplete="off"
aria-describedby="date-hint"
>
<button
type="button"
class="datepicker-toggle"
aria-label="Open calendar"
aria-expanded="false"
aria-controls="calendar-popover"
>
<!-- Calendar icon SVG -->
</button>
</div>
<span class="sr-only" id="date-hint">Format: MM/DD/YYYY</span>
<div
class="datepicker-calendar"
id="calendar-popover"
role="dialog"
aria-modal="true"
aria-label="Choose a date"
>
<div class="calendar-header">
<button type="button" aria-label="Previous month">
<!-- Left arrow icon -->
</button>
<div id="calendar-title" aria-live="polite">
April 2026
</div>
<button type="button" aria-label="Next month">
<!-- Right arrow icon -->
</button>
</div>
<table role="grid" aria-labelledby="calendar-title">
<thead>
<tr>
<th scope="col" abbr="Sunday">Su</th>
<th scope="col" abbr="Monday">Mo</th>
<th scope="col" abbr="Tuesday">Tu</th>
<th scope="col" abbr="Wednesday">We</th>
<th scope="col" abbr="Thursday">Th</th>
<th scope="col" abbr="Friday">Fr</th>
<th scope="col" abbr="Saturday">Sa</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td>
<button
type="button"
role="gridcell"
tabindex="-1"
aria-label="April 1, 2026"
>1</button>
</td>
<!-- ... more days -->
</tr>
<!-- ... more weeks -->
</tbody>
</table>
<div role="status" aria-live="polite"
class="sr-only"></div>
</div>
</div>
.datepicker-wrapper {
position: relative;
display: inline-block;
max-width: 20rem;
}
.datepicker-input-group {
display: flex;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
}
.datepicker-input-group:focus-within {
border-color: #5b2a86;
box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.15);
}
.datepicker-input {
flex: 1;
border: none;
background: transparent;
padding: 0.5rem 0.75rem;
font-size: 1rem;
min-height: 2.75rem;
outline: none;
}
.datepicker-toggle {
width: 2.75rem;
height: 2.75rem;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Calendar popover */
.datepicker-calendar {
display: none;
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
z-index: 100;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 1rem;
width: 20rem;
}
.datepicker-calendar.is-open {
display: block;
}
/* Calendar grid */
.calendar-grid {
width: 100%;
border-collapse: collapse;
text-align: center;
}
.calendar-day {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: none;
background: transparent;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
}
.calendar-day:hover {
background: #f3f4f6;
}
/* Focus styles: SC 2.4.13 */
.calendar-day:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
}
/* Today indicator */
.calendar-day[aria-current="date"] {
font-weight: 700;
border: 2px solid #5b2a86;
}
/* Selected date */
.calendar-day[aria-selected="true"] {
background: #5b2a86;
color: #fff;
font-weight: 600;
}
class DatePicker {
constructor(wrapper) {
this.wrapper = wrapper;
this.input = wrapper.querySelector('.datepicker-input');
this.toggle = wrapper.querySelector('.datepicker-toggle');
this.calendar = wrapper.querySelector('.datepicker-calendar');
this.body = wrapper.querySelector('#calendar-body');
this.title = wrapper.querySelector('#calendar-title');
this.prevBtn = wrapper.querySelector('#prev-month');
this.nextBtn = wrapper.querySelector('#next-month');
this.status = wrapper.querySelector('#calendar-status');
this.isOpen = false;
this.focusedDate = new Date();
this.selectedDate = null;
this.today = new Date();
this.monthNames = [
'January','February','March','April','May','June',
'July','August','September','October','November','December'
];
this.init();
}
init() {
this.toggle.addEventListener('click', () => this.toggleCalendar());
this.prevBtn.addEventListener('click', () => this.changeMonth(-1));
this.nextBtn.addEventListener('click', () => this.changeMonth(1));
this.calendar.addEventListener('keydown', (e) => this.handleKeydown(e));
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.wrapper.contains(e.target)) {
this.close();
}
});
// Parse initial input value if present
this.renderCalendar();
}
toggleCalendar() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.calendar.classList.add('is-open');
this.toggle.setAttribute('aria-expanded', 'true');
this.renderCalendar();
this.focusDate(this.focusedDate);
}
close() {
this.isOpen = false;
this.calendar.classList.remove('is-open');
this.toggle.setAttribute('aria-expanded', 'false');
this.toggle.focus();
}
changeMonth(delta) {
this.focusedDate.setMonth(this.focusedDate.getMonth() + delta);
this.renderCalendar();
this.focusDate(this.focusedDate);
}
selectDate(date) {
this.selectedDate = new Date(date);
this.focusedDate = new Date(date);
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
this.input.value = `${m}/${d}/${date.getFullYear()}`;
this.status.textContent =
`Selected ${this.formatDateLong(date)}`;
this.renderCalendar();
this.close();
}
formatDateLong(date) {
return `${this.monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
isToday(date) {
return date.getDate() === this.today.getDate()
&& date.getMonth() === this.today.getMonth()
&& date.getFullYear() === this.today.getFullYear();
}
isSameDay(a, b) {
return a && b
&& a.getDate() === b.getDate()
&& a.getMonth() === b.getMonth()
&& a.getFullYear() === b.getFullYear();
}
renderCalendar() {
const year = this.focusedDate.getFullYear();
const month = this.focusedDate.getMonth();
this.title.textContent =
`${this.monthNames[month]} ${year}`;
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
let html = '';
let day = 1;
for (let week = 0; week < 6; week++) {
if (day > daysInMonth) break;
html += '<tr>';
for (let dow = 0; dow < 7; dow++) {
if ((week === 0 && dow < firstDay) || day > daysInMonth) {
html += '<td></td>';
} else {
const date = new Date(year, month, day);
const label = this.formatDateLong(date);
const isSelected = this.isSameDay(date, this.selectedDate);
const isTodayDate = this.isToday(date);
const tabIdx = this.isSameDay(date, this.focusedDate) ? 0 : -1;
html += `<td><button type="button" role="gridcell"
class="calendar-day"
tabindex="${tabIdx}"
aria-label="${label}"
${isSelected ? 'aria-selected="true"' : ''}
${isTodayDate ? 'aria-current="date"' : ''}
data-date="${year}-${month}-${day}"
>${day}</button></td>`;
day++;
}
}
html += '</tr>';
}
this.body.innerHTML = html;
// Attach click handlers to day buttons
this.body.querySelectorAll('.calendar-day').forEach(btn => {
btn.addEventListener('click', () => {
const [y, m, d] = btn.dataset.date.split('-').map(Number);
this.selectDate(new Date(y, m, d));
});
});
}
focusDate(date) {
const btn = this.body.querySelector(
`[data-date="${date.getFullYear()}-${date.getMonth()}-${date.getDate()}"]`
);
if (btn) {
// Reset all tabindex, set focused one to 0
this.body.querySelectorAll('.calendar-day')
.forEach(b => b.setAttribute('tabindex', '-1'));
btn.setAttribute('tabindex', '0');
btn.focus();
}
}
handleKeydown(e) {
const key = e.key;
let handled = true;
switch (key) {
case 'ArrowRight':
this.moveFocus(1); break;
case 'ArrowLeft':
this.moveFocus(-1); break;
case 'ArrowDown':
this.moveFocus(7); break;
case 'ArrowUp':
this.moveFocus(-7); break;
case 'Home':
this.moveFocusToStartOfWeek(); break;
case 'End':
this.moveFocusToEndOfWeek(); break;
case 'PageUp':
e.shiftKey ? this.changeYear(-1) : this.changeMonth(-1);
break;
case 'PageDown':
e.shiftKey ? this.changeYear(1) : this.changeMonth(1);
break;
case 'Enter':
case ' ':
this.selectDate(new Date(this.focusedDate));
break;
case 'Escape':
this.close(); break;
default:
handled = false;
}
if (handled) e.preventDefault();
}
moveFocus(days) {
this.focusedDate.setDate(this.focusedDate.getDate() + days);
this.renderCalendar();
this.focusDate(this.focusedDate);
}
moveFocusToStartOfWeek() {
const dow = this.focusedDate.getDay();
this.moveFocus(-dow);
}
moveFocusToEndOfWeek() {
const dow = this.focusedDate.getDay();
this.moveFocus(6 - dow);
}
changeYear(delta) {
this.focusedDate.setFullYear(
this.focusedDate.getFullYear() + delta
);
this.renderCalendar();
this.focusDate(this.focusedDate);
}
}
// 03 · why these decisions
Why These Decisions
Why a text input as the primary control?
The text input is the most universally accessible way to enter a date. Screen reader users, keyboard-only users, and people using voice control can all type a date directly without needing to interact with a calendar grid. The calendar popover is an enhancement, not a requirement. If JavaScript fails to load, the text input still works.
Why role="grid" for the calendar?
A calendar is inherently a two-dimensional data structure: days organized into weeks. The role="grid" pattern communicates this structure to screen readers and enables two-dimensional arrow key navigation. Screen readers announce the row and column context as users navigate, helping them understand where they are within the month.
Why roving tabindex instead of aria-activedescendant?
Roving tabindex moves actual DOM focus to each date button, which means screen readers reliably announce the focused cell's label and state. With aria-activedescendant, some screen reader and browser combinations fail to announce the virtually focused element. Roving tabindex has broader, more consistent support for grid patterns.
Why not use native <input type="date">?
The native date input varies wildly across browsers and platforms. Its calendar UI is not customizable, the format display is locale-dependent and sometimes confusing, and its screen reader support is inconsistent. A custom date picker gives you full control over the experience while ensuring consistent accessibility across all platforms.
Why role="dialog" and aria-modal="true" on the calendar?
The calendar popover acts as a modal context: when it opens, the user should interact only with the calendar until they select a date or dismiss it. The role="dialog" with aria-modal="true" tells screen readers that content outside the calendar is temporarily irrelevant, preventing virtual cursor navigation from wandering outside the popover.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Enter or Space | Opens the calendar (on the toggle button) or selects the focused date (inside the calendar) |
| Escape | Closes the calendar and returns focus to the toggle button |
| Arrow Right | Moves focus to the next day |
| Arrow Left | Moves focus to the previous day |
| Arrow Down | Moves focus to the same day of the next week |
| Arrow Up | Moves focus to the same day of the previous week |
| Home | Moves focus to the first day (Sunday) of the current week |
| End | Moves focus to the last day (Saturday) of the current week |
| Page Up | Moves focus to the same date in the previous month |
| Page Down | Moves focus to the same date in the next month |
| Shift + Page Up | Moves focus to the same date in the previous year |
| Shift + Page Down | Moves focus to the same date in the next year |
tabindex="0" at a time. All other dates have tabindex="-1". This means pressing Tab moves focus out of the grid entirely (to the next/previous month buttons), while arrow keys navigate between dates. This follows the WAI-ARIA grid pattern.
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
1.3.1 Info and Relationships
Level A
— The calendar uses
role="grid"with proper table markup so the two-dimensional date structure is programmatically conveyed. Labels, headers, and groupings are all semantically connected. - 2.1.1 Keyboard Level A — All date picker functionality is operable via keyboard: opening the calendar, navigating dates, selecting a date, and closing the popover.
- 2.4.7 Focus Visible Level AA — The focused date has a visible 2px outline that meets contrast requirements. The text input and toggle button also have clear focus indicators.
- 2.5.8 Target Size (Minimum) Level AA — Each calendar day button is 36x36px, exceeding the 24x24px minimum. The toggle button is 44x44px. The navigation buttons meet the minimum target size.
-
4.1.2 Name, Role, Value
Level A
— Each date button has an
aria-labelwith the full date,role="gridcell", andaria-selected/aria-currentstates. The toggle button usesaria-expandedto communicate calendar visibility.
// 06 · screen reader behavior
Screen Reader Behavior
NVDA (Windows)
- Opening the calendar: "Choose a date, dialog" — announces the dialog role and label
- Navigating dates: "April 15, 2026, row 3, column 4" — announces the full date and grid position
- Selecting a date: "Selected April 15, 2026" — the live region announces the selection
- Today's date: "April 11, 2026, current date" — the
aria-currentattribute is announced
JAWS (Windows)
- Opening the calendar: "Choose a date dialog" — announces dialog label
- Navigating dates: "April 15, 2026" — announces the
aria-labelof each cell - Selected state: "Selected" — announces when a date has
aria-selected="true"
VoiceOver (macOS / iOS)
- Opening the calendar: "Choose a date, web dialog" — adds "web" prefix
- Navigating dates: "April 15, 2026, row 3 of 5, column 4 of 7" — detailed grid position
- Today: "Current date" —
aria-current="date"is announced as "current date" - Selection: "Selected" — announced when
aria-selected="true"
// 07 · common mistakes
Common Mistakes
role="listbox" or flat list for calendar dates
A calendar is a two-dimensional grid, not a flat list. Using role="listbox" or a list of buttons loses the week-row structure and makes arrow key navigation confusing. Use role="grid" with proper row/cell structure.
aria-label on date cells
If each date button only contains a number (e.g., "15"), screen readers announce just "15, button" with no month or year context. Every date cell needs a full aria-label like "April 15, 2026."
<input type="date">
The native date input has inconsistent screen reader support and provides no way to customize the calendar UI, format, or accessible labels. It may work for simple cases but fails to meet WCAG requirements consistently across all browser/AT combinations.
aria-current="date" for today
Without marking today's date, users navigating by keyboard or screen reader have no reference point in the calendar. Today should always be visually and programmatically indicated with aria-current="date".