Overview
The Date Picker (also called a calendar, date input, or date selector) is one of the most complex interactive components in UI design. It allows users to select a single date, a date range, or a date-time combination through a calendar interface, a text input, or both.
Date pickers sit at the intersection of several hard problems: internationalization (date formats, calendars, first-day-of-week), timezone handling, accessibility (navigating a grid of 42 cells by keyboard), validation (min/max dates, disabled dates, business rules), and responsive design (calendars are inherently wide).
When to use a Date Picker:
- Selecting a specific calendar date (birth date, appointment, deadline)
- Selecting a date range (hotel check-in/check-out, reporting period)
- When the user needs to see surrounding dates for context (availability, pricing)
- When relative date context matters (is it a weekday? which week of the month?)
When NOT to use a Date Picker:
- For known dates the user can type easily (birth date) — a simple Text Input with date masking (
MM/DD/YYYY) may be faster. Offer both input + calendar. - For relative dates ("last 7 days", "this quarter") — use a Select with predefined options.
- For time-only input — use a time picker (separate component or input with
type="time"). - For far-future dates (year 2040) — navigating month-by-month is tedious. Add year/month dropdowns or a text input fallback.
The best date picker implementations offer dual input: a text field where users can type a date directly, plus a calendar popover for visual selection. This serves both power users (who type faster) and visual users (who want to see context).
Ensure your calendar's date numbers and navigation icons pass contrast requirements — use our Contrast Checker. Style your calendar's selected-date highlight with the Color Palette Generator.
Variants
Functional Variants
| Variant | Description | Use Case |
|---|---|---|
| Single date | Select one specific date | Birth date, deadline, appointment |
| Date range | Select a start and end date | Hotel booking, reporting period, leave request |
| Multi-date | Select multiple non-contiguous dates | Scheduling recurring events, marking exceptions |
| Date-time | Date + time picker combined | Meeting scheduler, event creation, timestamps |
| Month picker | Select a month and year (no day) | Credit card expiry, monthly reports |
| Year picker | Select a year only | Historical data filtering, birth year |
Display Variants
| Variant | Description |
|---|---|
| Input + Popover | A text input with a calendar icon that opens a popover calendar. Default and most common. |
| Inline calendar | Calendar is always visible, embedded in the page. No popover. For booking/scheduling UIs. |
| Dual calendar | Two months displayed side-by-side. Essential for date range selection. |
| Input-only | Text input with date masking, no calendar. For known dates (birth date). |
Size Variants
| Size | Input Height | Calendar Width | Use Case |
|---|---|---|---|
| Small | 32px | 280px | Dense forms, inline filters |
| Medium | 40px | 320px | Default forms |
| Large | 48px | 360px | Mobile-first, booking interfaces |
Properties
Date Picker Properties
| Property | Type | Default | Description |
|---|---|---|---|
value | Date | null | null | Selected date (controlled) |
onChange | (date: Date | null) => void | — | Callback when date changes |
defaultValue | Date | — | Initial date (uncontrolled) |
minDate | Date | — | Earliest selectable date |
maxDate | Date | — | Latest selectable date |
disabledDates | (date: Date) => boolean | — | Function to disable specific dates (holidays, unavailable days) |
locale | string | Browser locale | BCP 47 locale string ('en-US', 'de-DE', 'ja-JP') |
firstDayOfWeek | 0-6 | Locale default | 0 = Sunday, 1 = Monday |
format | string | Locale default | Display format ('MM/dd/yyyy', 'dd.MM.yyyy') |
placeholder | string | Format string | Input placeholder text |
required | boolean | false | Form validation required |
disabled | boolean | false | Disables the entire picker |
readOnly | boolean | false | Allows viewing the calendar but not changing the value |
error | string | — | Error message |
clearable | boolean | false | Shows a clear button to reset the value |
Date Range Properties (additional)
| Property | Type | Default | Description |
|---|---|---|---|
startDate | Date | null | null | Range start |
endDate | Date | null | null | Range end |
onRangeChange | (range: { start: Date; end: Date }) => void | — | Callback for range changes |
minLength | number | — | Minimum range length in days |
maxLength | number | — | Maximum range length in days |
Token Mappings
Design Token Mappings
| Token Category | Token Example | Date Picker Usage |
|---|---|---|
| Color – Input | --color-surface, --color-border | Input field background and border |
| Color – Calendar Surface | --color-surface-elevated | Calendar popover background |
| Color – Selected | --color-primary-600 | Selected date circle fill. Choose accessible hues with Color Palette Generator. |
| Color – Selected Text | --color-on-primary | Text on the selected date |
| Color – Range | --color-primary-100 | Background highlight for dates within a selected range |
| Color – Today | --color-primary-600 (outline) | Today's date indicator (typically a ring or dot) |
| Color – Disabled | --color-text-disabled | Dates outside the selectable range |
| Color – Hover | --color-surface-hover | Date cell hover background |
| Color – Adjacent Month | --color-text-tertiary | Dates from previous/next month (dimmed) |
| Border Radius | --radius-full (9999px) | Selected date circle |
| Border Radius – Calendar | --radius-xl (16px) | Calendar popover corners |
| Shadow | --shadow-lg | Calendar popover elevation |
| Spacing | --space-1 (4px) | Gap between date cells |
| Typography | --font-size-sm, --font-variant-numeric: tabular-nums | Date numbers (tabular for alignment) |
See our Design Tokens Complete Guide for naming conventions.
States
Input States
| State | Visual Treatment |
|---|---|
| Empty | Placeholder showing the expected format (e.g., "MM/DD/YYYY") |
| Filled | Formatted date displayed in the input |
| Focused | Input border highlighted. Calendar popover opens (optional — some implementations open on icon click only). |
| Error | Red border, error message below. Invalid date entered or required field empty. |
| Disabled | Greyed out input and calendar icon. Not interactive. |
Calendar States
| State | Visual Treatment |
|---|---|
| Idle | Current month displayed. Today highlighted with subtle indicator (dot or ring). |
| Date hover | Background highlight on hovered date cell |
| Date selected | Solid circle (primary color) on the selected date. White text. |
| Range: start/end | Solid circles on start and end dates |
| Range: between | Light primary background connecting start and end dates |
| Range: preview | During selection (start chosen, hovering for end), show a preview of the range with lighter styling |
| Disabled date | Muted text, no hover effect, not selectable. Use aria-disabled="true". |
| Adjacent month | Dates from previous/next month shown in muted text at the edges of the grid |
| Month/Year navigation | Prev/Next arrows (◀ ▶) for month navigation. Month/year header may be clickable for month/year picker views. |
Calendar Navigation States
| View | Description |
|---|---|
| Day view | Default. 6×7 grid of dates for one month. |
| Month view | 3×4 grid of months. User picks a month, returns to day view. |
| Year view | Grid of years (e.g., 2020–2035). User picks a year, goes to month view, then day view. |
| Decade view | Range of decades for far-past/future date selection. |
Accessibility
WCAG Requirements
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.3.1 Info and Relationships | A | Calendar grid must use role="grid" with proper role="row" and role="gridcell" structure. Day-of-week headers need role="columnheader". |
| SC 1.4.3 Contrast (Minimum) | AA | Date numbers: 4.5:1 against cell background. Selected date text: 4.5:1 against the primary fill. Disabled dates: no contrast requirement, but ensure they're visually distinct. Verify with Contrast Checker. |
| SC 1.4.11 Non-text Contrast | AA | Today indicator, selection ring, and navigation arrows: 3:1 contrast. |
| SC 2.1.1 Keyboard | A | Full keyboard navigation through the calendar grid, month/year navigation, and date selection. |
| SC 2.4.7 Focus Visible | AA | Currently focused date cell must have a visible focus indicator. |
| SC 3.3.2 Labels or Instructions | A | Input must have a visible label. Expected format should be communicated (placeholder or helper text). |
ARIA Attributes
- Calendar container:
role="dialog"witharia-label="Choose date"oraria-labelledby - Grid:
role="grid"witharia-label="[Month Year]"(e.g., "March 2026") - Day cells:
role="gridcell". Selected:aria-selected="true". Disabled:aria-disabled="true". - Today: Mark with
aria-current="date" - Navigation buttons:
aria-label="Previous month",aria-label="Next month" - Live announcements: When the month changes, use an
aria-live="polite"region to announce "March 2026"
Keyboard Interaction
| Key | Action |
|---|---|
| Arrow Right | Move focus to next day |
| Arrow Left | Move focus to previous day |
| Arrow Down | Move focus to same day next week |
| Arrow Up | Move focus to same day previous week |
| Home | Move focus to first day of the current week (or month) |
| End | Move focus to last day of the current week (or month) |
| Page Down | Move to same date in next month |
| Page Up | Move to same date in previous month |
| Shift + Page Down | Move to same date in next year |
| Shift + Page Up | Move to same date in previous year |
| Enter / Space | Select the focused date |
| Escape | Close the calendar popover, return focus to input |
This keyboard model follows the WAI-ARIA grid pattern and is the expected behavior for date pickers. Users who rely on keyboard navigation depend on these exact keys.
Common Accessibility Mistakes
- No keyboard navigation in the calendar grid. This is the most common failure — many date pickers are mouse-only.
- Missing live region announcements when navigating between months. Without them, screen reader users don't know the month changed.
- Relying on color alone to indicate "selected" or "today." Add a shape change (filled circle vs. outline) in addition to color.
- No text input fallback. Some users (especially screen reader users) strongly prefer typing a date directly. Always offer a typeable input alongside the visual calendar.
For comprehensive patterns, see our ARIA Attributes Guide and Keyboard Accessibility Guide.
Usage Guidelines
Do's
- ✅ Always provide a text input alongside the calendar. Power users type dates faster than they click them. Screen reader users may prefer text input entirely.
- ✅ Show the expected date format in the placeholder or helper text. "MM/DD/YYYY" isn't universal — Europeans expect "DD/MM/YYYY" or "DD.MM.YYYY".
- ✅ Respect the user's locale. Use the
Intl.DateTimeFormatAPI to format dates. First day of week varies by country (Sunday in US, Monday in Europe, Saturday in Middle East). - ✅ Pre-navigate to a sensible month. If picking a future date, start on the current month. If editing an existing date, start on that date's month.
- ✅ Use dual calendars for range selection. Seeing two months side-by-side makes range selection intuitive. Single calendars require tedious month-switching.
- ✅ Disable unavailable dates visually AND semantically (
aria-disabled="true"). Show why they're disabled via tooltip if possible.
Don'ts
- ❌ Don't use a date picker for birth dates in most contexts. A text input with validation is faster — users know their birthday and don't want to click back 30 years in a calendar. If you must use a calendar, provide year/month dropdowns.
- ❌ Don't auto-close the calendar on date selection for range pickers. The user still needs to pick the end date.
- ❌ Don't use ambiguous formats without labeling. Is "03/04/2026" March 4th or April 3rd? Always label the format or use unambiguous display ("Mar 4, 2026").
- ❌ Don't allow invalid date entry without feedback. If the user types "02/31/2026", show an immediate error — don't silently correct it or fail on submit.
- ❌ Don't forget timezone handling. If the date affects scheduling, clarify the timezone. A date picker alone doesn't communicate timezone — add it in helper text or a separate selector.
Internationalization Checklist
- Date format follows locale conventions
- First day of week is locale-appropriate
- Month and day names are translated
- Calendar direction matches text direction (RTL support)
- Non-Gregorian calendars supported where needed (Islamic, Hebrew, Japanese)
- Date parsing handles multiple input formats gracefully
Code Snippets
<!-- Date Picker: Input + Popover Calendar -->
<div class="date-picker">
<label for="date-input" id="date-label">Departure date</label>
<div class="date-input-wrapper">
<input
type="text"
id="date-input"
placeholder="MM/DD/YYYY"
aria-describedby="date-format"
aria-haspopup="dialog"
autocomplete="off"
/>
<button
type="button"
class="calendar-toggle"
aria-label="Open calendar"
aria-expanded="false"
aria-controls="calendar-dialog"
>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.75 2a.75.75 0 0 1 .75.75V4h7V2.75a.75.75 0 0 1 1.5 0V4h1.25A1.75 1.75 0 0 1 18 5.75v10.5A1.75 1.75 0 0 1 16.25 18H3.75A1.75 1.75 0 0 1 2 16.25V5.75A1.75 1.75 0 0 1 3.75 4H5V2.75A.75.75 0 0 1 5.75 2ZM5 5.5H3.75a.25.25 0 0 0-.25.25V8h13V5.75a.25.25 0 0 0-.25-.25H5Zm11.5 4h-13v6.75c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V9.5Z"/>
</svg>
</button>
<span id="date-format" class="helper-text">Format: MM/DD/YYYY</span>
</div>
<!-- Calendar Dialog -->
<div
id="calendar-dialog"
role="dialog"
aria-label="Choose departure date"
aria-modal="true"
class="calendar-popover"
hidden
>
<div class="calendar-header">
<button type="button" aria-label="Previous month">◀</button>
<span aria-live="polite" class="calendar-title">March 2026</span>
<button type="button" aria-label="Next month">▶</button>
</div>
<table role="grid" aria-label="March 2026">
<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><button type="button" tabindex="-1" aria-disabled="true" class="date-cell adjacent">22</button></td>
<td><button type="button" tabindex="-1" aria-disabled="true" class="date-cell adjacent">23</button></td>
<!-- ... more cells ... -->
<td><button type="button" tabindex="0" aria-current="date" class="date-cell today">8</button></td>
<!-- ... more cells ... -->
</tr>
</tbody>
</table>
</div>
</div>import { useState, useRef, useEffect, useMemo } from "react";
interface DatePickerProps {
value?: Date | null;
onChange?: (date: Date | null) => void;
label: string;
placeholder?: string;
minDate?: Date;
maxDate?: Date;
disabledDates?: (date: Date) => boolean;
locale?: string;
error?: string;
}
export default function DatePicker({
value = null,
onChange,
label,
placeholder = "MM/DD/YYYY",
minDate,
maxDate,
disabledDates,
locale = "en-US",
error,
}: DatePickerProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(value || new Date());
const [focusDate, setFocusDate] = useState(value || new Date());
const inputRef = useRef<HTMLInputElement>(null);
const calendarRef = useRef<HTMLDivElement>(null);
const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate();
const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1).getDay();
const monthLabel = viewDate.toLocaleDateString(locale, { month: "long", year: "numeric" });
const isDisabled = (date: Date) => {
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
if (disabledDates?.(date)) return true;
return false;
};
const selectDate = (day: number) => {
const date = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
if (isDisabled(date)) return;
onChange?.(date);
setOpen(false);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent, day: number) => {
const current = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
let next = new Date(current);
switch (e.key) {
case "ArrowRight": next.setDate(next.getDate() + 1); break;
case "ArrowLeft": next.setDate(next.getDate() - 1); break;
case "ArrowDown": next.setDate(next.getDate() + 7); break;
case "ArrowUp": next.setDate(next.getDate() - 7); break;
case "Enter":
case " ": e.preventDefault(); selectDate(day); return;
case "Escape": setOpen(false); inputRef.current?.focus(); return;
default: return;
}
e.preventDefault();
setFocusDate(next);
if (next.getMonth() !== viewDate.getMonth()) setViewDate(next);
};
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const isToday = (day: number) => {
const now = new Date();
return day === now.getDate()
&& viewDate.getMonth() === now.getMonth()
&& viewDate.getFullYear() === now.getFullYear();
};
return (
<div className="date-picker">
<label htmlFor="date-input">{label}</label>
<div className="date-input-wrapper">
<input
ref={inputRef}
id="date-input"
type="text"
placeholder={placeholder}
value={value?.toLocaleDateString(locale) ?? ""}
aria-invalid={!!error || undefined}
aria-haspopup="dialog"
readOnly
onClick={() => setOpen(!open)}
/>
</div>
{error && <span className="error-text" role="alert">{error}</span>}
{open && (
<div ref={calendarRef} role="dialog" aria-label={`Choose ${label}`} aria-modal="true" className="calendar-popover">
<div className="calendar-header">
<button type="button" aria-label="Previous month" onClick={() => setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1))}>◀</button>
<span aria-live="polite">{monthLabel}</span>
<button type="button" aria-label="Next month" onClick={() => setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1))}>▶</button>
</div>
<table role="grid" aria-label={monthLabel}>
<thead>
<tr>{["Su","Mo","Tu","We","Th","Fr","Sa"].map(d => <th key={d} scope="col">{d}</th>)}</tr>
</thead>
<tbody>
{Array.from({ length: Math.ceil(cells.length / 7) }, (_, row) => (
<tr key={row}>
{cells.slice(row * 7, row * 7 + 7).map((day, i) => (
<td key={i}>
{day && (
<button
type="button"
className={`date-cell ${isToday(day) ? "today" : ""}`}
tabIndex={focusDate.getDate() === day ? 0 : -1}
aria-selected={value?.getDate() === day && value?.getMonth() === viewDate.getMonth()}
aria-current={isToday(day) ? "date" : undefined}
aria-disabled={isDisabled(new Date(viewDate.getFullYear(), viewDate.getMonth(), day)) || undefined}
onClick={() => selectDate(day)}
onKeyDown={(e) => handleKeyDown(e, day)}
>
{day}
</button>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}Design Systems
Cross-System Comparison
| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Component | DatePicker (Material UI) | Calendar + Popover (manual composition) | No date primitive | DatePicker, RangePicker |
| Calendar library | @mui/x-date-pickers (day.js/luxon/moment adapter) | react-day-picker (recommended) | N/A | dayjs internally (rc-picker) |
| Range support | DateRangePicker (MUI X Pro $) | Manual with react-day-picker | N/A | DatePicker.RangePicker |
| Keyboard | Full grid navigation | Depends on underlying lib | N/A | Full grid navigation |
| Locale support | Via date adapter | Via react-day-picker locales | N/A | Built-in, 50+ locales |
| Timezone | @mui/x-date-pickers v6+ timezone prop | Manual | N/A | dayjs timezone plugin |
| Text input | Combined input + calendar | Separate (compose yourself) | N/A | Integrated input + calendar |
| Time support | DateTimePicker | Manual time input | N/A | DatePicker with showTime |
Notable Approaches
MUI X Date Pickers is the most comprehensive date picker in the React ecosystem. The free tier handles single date and date-time. The Pro tier ($) adds date range, time range, and date-time range pickers. It uses an adapter pattern for date libraries (dayjs, luxon, date-fns, moment) so you aren't locked into one. Timezone support landed in v6 — critical for scheduling applications.
Shadcn/ui doesn't provide a date picker component directly. Instead, it documents a pattern: compose a Popover + Calendar (powered by react-day-picker) + a text Input. This composition approach gives full control but requires more assembly. The react-day-picker library itself is excellent — lightweight, accessible, and locale-aware.
Ant Design's DatePicker is batteries-included: single date, range, week picker, month picker, quarter picker, and year picker — all in one component family. It handles locale formatting, disabled dates, preset ranges ("Last 7 days"), and custom cell rendering. The trade-off is bundle size and Ant's opinionated styling.
Radix intentionally has no date picker primitive. Date pickers involve date logic (calendar math, formatting, locale), which is fundamentally different from UI behavior (focus, keyboard, ARIA). Radix focuses on UI primitives and leaves date logic to dedicated libraries.
react-day-picker (used by Shadcn and many others) is worth highlighting: it provides the calendar grid with full keyboard navigation, ARIA roles, locale support, and flexible selection modes — all headless. You bring the styling, popover behavior, and input integration.
Native <input type="date">: Browser-native date inputs have improved dramatically. They're accessible, handle locale formatting, and provide a built-in calendar. However, styling is severely limited, range selection isn't supported, and the visual design varies across browsers. For simple forms where brand consistency isn't critical, native date inputs are a solid choice.
For building accessible calendar grids from scratch, see our Keyboard Accessibility Guide.