// 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.

aria-required wcag-2.2-aa advanced

// 01 · live demo

Live Demo

Format: MM/DD/YYYY

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

HTML
<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>
CSS
.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;
}
JavaScript
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
Roving Tabindex Only one date in the calendar grid has 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-label with the full date, role="gridcell", and aria-selected / aria-current states. The toggle button uses aria-expanded to 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-current attribute is announced

JAWS (Windows)

  • Opening the calendar: "Choose a date dialog" — announces dialog label
  • Navigating dates: "April 15, 2026" — announces the aria-label of 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

No text input fallback Requiring users to pick a date from a calendar grid with no option to type it manually. This forces screen reader and keyboard users through dozens of arrow key presses to reach a date. Always provide a text input as the primary control.
Using 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.
No 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."
Calendar opens but steals focus permanently If Escape doesn't close the calendar or focus doesn't return to the trigger, keyboard users are trapped. Always implement Escape-to-close and return focus to the toggle button.
Relying solely on <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.
Missing 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".