Date Picker
Design a calendar picker with correct month grid generation, a timezone model that separates the date value from its display, range selection, and full ARIA grid keyboard support.
Advertisement
The Problem
Design a date picker: a text input with a calendar popup, single or range selection, disabled dates, and correct behaviour in every locale.
It appears to be a formatting exercise. It is the interview problem with the highest density of silent bugs, because the two hardest parts fail invisibly. Timezone handling produces a value that is off by one day for a predictable subset of users and is correct in the developer's own timezone. Localization shifts every column of the grid when the week start is wrong, and looks perfectly fine to anyone in the locale you built it for.
Requirements
Functional
- Month grid with navigation by month and year.
- Single date and date range selection, with a hover preview for ranges.
- Disabled dates, plus min and max constraints.
- Text entry in the input as an alternative to the calendar.
- Full keyboard operation and screen reader support.
Non-functional
- The stored value means the same calendar day for every user, in every timezone.
- Week start, month names and field order follow the user's locale.
- No layout shift when navigating between months of different row counts.
- Popup grid renders in under a frame; navigation feels instant.
The Timezone Model
Start here, because it determines the type of every value in the component.
A calendar date and a moment in time are different kinds of value. "15 March 2026" is a whole day. "15 March 2026 at 09:30 UTC" is an instant. JavaScript's Date only models the second one, which is the root of the problem: it is a millisecond timestamp with locale-aware formatting bolted on.
The classic failure:
// A user in Los Angeles (UTC-8) picks the 15th of March.
const picked = new Date(2026, 2, 15); // local midnight = 15 Mar 00:00 PST
picked.toISOString(); // "2026-03-15T08:00:00.000Z" - fine
// A user in Auckland (UTC+13) picks the same day.
const pickedNZ = new Date(2026, 2, 15); // local midnight = 15 Mar 00:00 NZDT
pickedNZ.toISOString(); // "2026-03-14T11:00:00.000Z" - the 14th
// And reading a stored instant back in a different zone:
new Date('2026-03-15T00:00:00Z').toLocaleDateString('en-US', {
timeZone: 'America/Los_Angeles',
}); // "3/14/2026"
Nobody's arithmetic is wrong. A birthday was modelled as an instant, and an instant genuinely falls on different calendar dates in different places.
Diagram100%flowchart TB subgraph INPUT["1 - User picks a day"] U["User in Auckland (UTC+13)<br/>taps the 15th"] end subgraph DECIDE["2 - Which kind of value is this?"] Q{"a whole calendar day,<br/>or an instant?"} end subgraph PLAIN["Calendar date - no time, no zone"] P1["internal: { year: 2026, month: 3, day: 15 }"] P2["wire + storage: '2026-03-15'<br/>(ISO date, DATE column)"] P3["display: format(plainDate, locale)<br/>-> '15/03/2026' or '3/15/2026'"] P4["Same day for every viewer,<br/>in every zone. No conversion, ever."] end subgraph INSTANT["Instant - a real moment"] I1["combine day + time + the zone<br/>the event is defined in"] I2["internal: epoch millis"] I3["wire + storage: '2026-03-15T09:30:00Z'<br/>(TIMESTAMPTZ column)"] I4["display: format in the VIEWER's zone<br/>-> '14 Mar, 8:30 pm' in LA"] I5["Same moment for everyone;<br/>the displayed date may differ. Correct."] end U --> Q Q -->|"birthday, deadline,<br/>check-in date, invoice date"| P1 --> P2 --> P3 --> P4 Q -->|"meeting start, log entry,<br/>reminder time"| I1 --> I2 --> I3 --> I4 --> I5 BAD["ANTI-PATTERN<br/>new Date(y, m, d).toISOString()<br/>for a calendar date -<br/>silently off by one for<br/>everyone in another zone"] Q -.->|"the usual bug"| BAD style PLAIN fill:#1e3f2d,stroke:#22c55e style INSTANT fill:#1e3a5f,stroke:#3b82f6 style BAD fill:#3f1e1e,stroke:#ef4444visualized by
The rule that prevents all of it: decide which kind of value you have, and never let it change kind.
/** A calendar day. No time, no zone. Comparable and serialisable as a string. */
type PlainDate = { year: number; month: number; day: number }; // month is 1-12
function toIsoDate({ year, month, day }: PlainDate): string {
const mm = String(month).padStart(2, '0');
const dd = String(day).padStart(2, '0');
return `${year}-${mm}-${dd}`;
}
function fromIsoDate(iso: string): PlainDate {
const [year, month, day] = iso.split('-').map(Number);
return { year, month, day };
}
/** Ordering without ever constructing a Date. */
function comparePlainDates(a: PlainDate, b: PlainDate): number {
return (
a.year - b.year || a.month - b.month || a.day - b.day
);
}
/**
* Day arithmetic via UTC so no local timezone or DST rule can shift the result.
* Using new Date(y, m, d) here would reintroduce the bug this type exists to avoid.
*/
function addDays(date: PlainDate, days: number): PlainDate {
const utc = Date.UTC(date.year, date.month - 1, date.day);
const shifted = new Date(utc + days * 86_400_000);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
};
}
Two details carry this. PlainDate never touches the local timezone, so it cannot be shifted by one. And arithmetic goes through Date.UTC, because UTC has no daylight-saving transitions - new Date(2026, 2, 15) in a zone where DST begins that night can land on the wrong day when you add hours.
The Temporal API makes this native (Temporal.PlainDate versus Temporal.ZonedDateTime), and its existence is the strongest evidence that the distinction is real rather than pedantic. Until it is universally available, date-fns with explicit UTC handling, or a small PlainDate like the above, is the practical answer.
Three rules for the boundary:
- Store plain dates as
DATE, instants asTIMESTAMPTZ. The column type is the contract, and aTIMESTAMPTZholding a birthday is a bug waiting for a user in another timezone. - Never send a plain date as a full ISO timestamp.
"2026-03-15"on the wire; aZ-suffixed string is a different kind of value. - Display is derived at render, never stored. The same instant is legitimately "14 Mar" in Los Angeles and "15 Mar" in Auckland, and that is correct rather than a bug to be worked around.
Generating the Month Grid
Pure arithmetic over PlainDate, with the week start as a parameter.
const DAYS_IN_GRID = 42; // six weeks, so the popup height never changes
type DayCell = {
date: PlainDate;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isRangeStart: boolean;
isRangeEnd: boolean;
isInRange: boolean;
isDisabled: boolean;
};
/** 0 = Sunday ... 6 = Saturday, matching Date.prototype.getUTCDay. */
function weekdayOf(date: PlainDate): number {
return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
}
function buildMonthGrid(
year: number,
month: number,
weekStartsOn: number,
context: GridContext,
): DayCell[][] {
const firstOfMonth: PlainDate = { year, month, day: 1 };
// Modulo 7 keeps this non-negative when weekStartsOn is later in the week
// than the weekday the month begins on - a Monday-start January starting
// on a Sunday, for example.
const leading = (weekdayOf(firstOfMonth) - weekStartsOn + 7) % 7;
const gridStart = addDays(firstOfMonth, -leading);
const cells: DayCell[] = Array.from({ length: DAYS_IN_GRID }, (_, offset) => {
const date = addDays(gridStart, offset);
return {
date,
isCurrentMonth: date.month === month && date.year === year,
isToday: comparePlainDates(date, context.today) === 0,
isSelected: context.isSelected(date),
isRangeStart: context.isRangeStart(date),
isRangeEnd: context.isRangeEnd(date),
isInRange: context.isInRange(date),
isDisabled: context.isDisabled(date),
};
});
return Array.from({ length: 6 }, (_, week) =>
cells.slice(week * 7, week * 7 + 7),
);
}
Three decisions worth stating.
Fixed 42 cells. A month can occupy four to six week rows depending on length and start weekday. Rendering the minimum makes the popup change height as the user navigates, shifting whatever sits below it - a self-inflicted layout shift of the kind Performance Engineering warns about, in the one component where the user is actively clicking.
Leading and trailing days are rendered, not blank. They give the grid its shape and let a user near a boundary select the neighbouring month's date directly. They are visually de-emphasised and marked isCurrentMonth: false.
Everything derived, nothing stored. A cell's flags are computed from the current selection each render. Caching them is how a picker ends up showing a stale selection after the value changes externally.
Disabled logic composes several independent rules, and the order matters for the message you show:
function isDateDisabled(date: PlainDate, config: PickerConfig): boolean {
if (config.min && comparePlainDates(date, config.min) < 0) return true;
if (config.max && comparePlainDates(date, config.max) > 0) return true;
const weekday = weekdayOf(date);
if (config.disabledWeekdays?.includes(weekday)) return true;
const iso = toIsoDate(date);
// A Set of ISO strings, not Dates - string comparison cannot be off by one.
if (config.disabledDates?.has(iso)) return true;
return config.isDateUnavailable?.(date) ?? false;
}
Using a Set<string> of ISO dates rather than an array of Date objects is both faster and correct: two Date objects for the same calendar day are rarely equal, and comparing them requires normalising a time component that should not exist.
Range Selection
A range picker needs four pieces of state, and merging any of them produces a visible bug.
Diagram100%stateDiagram-v2 [*] --> Empty Empty --> StartChosen: click a day<br/>start = day, awaiting = 'end' Empty --> Empty: click a disabled day (rejected) StartChosen --> StartChosen: hover / arrow key<br/>hoverDate = day (preview only) StartChosen --> Complete: click a day AFTER start<br/>end = day StartChosen --> Complete: click a day BEFORE start<br/>swap - start = day, end = old start StartChosen --> Empty: click the same day again<br/>(single-day range or clear, per product) StartChosen --> Empty: Escape (cancel the pending range) Complete --> StartChosen: click any day<br/>begin a new range from it Complete --> Empty: clear button Complete --> [*]: popup closes, value committed note right of StartChosen hoverDate drives the preview highlight and must never be written into start or end. Keyboard users have no hover, so focusedDate substitutes. end notevisualized by
type RangeState = {
start: PlainDate | null;
end: PlainDate | null;
/** Which end the next click sets. The picker is in a different mode here. */
awaiting: 'start' | 'end';
/** Preview only. Separate from the committed value, always. */
hoverDate: PlainDate | null;
/** Where keyboard focus sits; substitutes for hover on the keyboard path. */
focusedDate: PlainDate;
};
function selectDate(state: RangeState, date: PlainDate): RangeState {
if (state.awaiting === 'start' || !state.start) {
return { ...state, start: date, end: null, awaiting: 'end', hoverDate: null };
}
// Clicking before the start expresses intent, not an error. Swap rather
// than reject - rejecting leaves the user stuck with no obvious recovery.
if (comparePlainDates(date, state.start) < 0) {
return { ...state, start: date, end: state.start, awaiting: 'start', hoverDate: null };
}
return { ...state, end: date, awaiting: 'start', hoverDate: null };
}
/** The preview range, from whichever pointer the user is actually using. */
function previewEnd(state: RangeState): PlainDate | null {
if (state.awaiting !== 'end' || !state.start) return null;
return state.hoverDate ?? state.focusedDate;
}
function isInRange(state: RangeState, date: PlainDate): boolean {
const end = state.end ?? previewEnd(state);
if (!state.start || !end) return false;
const [from, to] =
comparePlainDates(state.start, end) <= 0 ? [state.start, end] : [end, state.start];
return (
comparePlainDates(date, from) >= 0 && comparePlainDates(date, to) <= 0
);
}
Three points that separate a good range picker from a frustrating one:
- Preview is not selection. If hover writes into
end, moving the mouse appears to change the value, and a mouse leaving the calendar leaves a half-committed range. - Swap instead of reject. A user clicking backwards means "actually, start here". Rejecting the click gives them no path forward.
focusedDatesubstitutes for hover. Keyboard users have no pointer, so without this the preview never appears for them and the range is invisible until committed - a case that is easy to miss and immediately obvious to anyone testing without a mouse.
Localization
The two settings most likely to be wrong are invisible to the developer who built the picker.
/**
* Week start by locale. Monday in most of Europe, Sunday in the US, Japan and
* much of Latin America, Saturday in parts of the Middle East. Getting this
* wrong shifts every column and looks completely fine in your own locale.
*/
function getWeekStart(locale: string): number {
const info = new Intl.Locale(locale).weekInfo;
// weekInfo uses 1 = Monday ... 7 = Sunday; getUTCDay uses 0 = Sunday.
return info ? info.firstDay % 7 : 1;
}
/** Weekday headers in the right order for the locale, correctly abbreviated. */
function getWeekdayLabels(locale: string, weekStartsOn: number): string[] {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC',
});
return Array.from({ length: 7 }, (_, offset) => {
// 4 Jan 1970 was a Sunday, so this indexes weekdays without any local
// timezone involvement.
const day = new Date(Date.UTC(1970, 0, 4 + ((weekStartsOn + offset) % 7)));
return formatter.format(day);
});
}
function formatMonthHeading(year: number, month: number, locale: string): string {
return new Intl.DateTimeFormat(locale, {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(Date.UTC(year, month - 1, 1)));
}
Intl is doing real work here that a hand-written array cannot. A hardcoded ['Sun', 'Mon', ...] is a picker that is only correct in English, and hardcoded month names are the most reliable indicator that localization was never considered.
Parsing typed input is the harder half, because new Date(string) is not a date parser:
/**
* Discover the locale's field order by formatting a date whose parts are
* unambiguous, then read the order back from the formatted output.
*/
function getDateFieldOrder(locale: string): ('day' | 'month' | 'year')[] {
const parts = new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
}).formatToParts(new Date(Date.UTC(2026, 2, 15)));
return parts
.filter((part) => part.type === 'day' || part.type === 'month' || part.type === 'year')
.map((part) => part.type as 'day' | 'month' | 'year');
}
function parseTypedDate(input: string, locale: string): PlainDate | null {
const numbers = input.match(/\d+/g);
if (!numbers || numbers.length < 3) return null;
const order = getDateFieldOrder(locale);
const values: Record<string, number> = {};
order.forEach((field, index) => {
values[field] = Number(numbers[index]);
});
const { year, month, day } = values;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
// Reject dates that do not exist - 31 February parses arithmetically and
// silently rolls into March if you let Date do the work.
const probe = new Date(Date.UTC(year, month - 1, day));
if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;
return { year, month, day };
}
Two traps. 03/04/2026 is 3 April in most of the world and 4 March in the United States, and there is no way to disambiguate without knowing the locale - which is why the field order must be discovered rather than assumed. And new Date(2026, 1, 31) silently becomes 3 March, so an existence check is required or the picker accepts dates that do not exist.
Beyond this, three things need mentioning for completeness: week numbering rules differ by locale if you display week numbers; non-Gregorian calendars are the correct default in some locales, and Intl.DateTimeFormat supports them through the calendar option; and some dates genuinely do not exist in some zones, which is one more reason plain dates should never be converted through a timezone. The broader framework for all of this - what varies, what Intl provides, and why formatting must be data-driven - is in Internationalization Architecture.
Keyboard and ARIA
A calendar is the grid pattern: a two-dimensional structure navigated with arrow keys, exposed as a single tab stop.
<div
role='dialog'
aria-modal='false'
aria-label='Choose a date'>
<div className='flex items-center justify-between'>
<button type='button' onClick={previousMonth} aria-label='Previous month'>
<ChevronLeft aria-hidden className='h-4 w-4' />
</button>
{/* aria-live so paging months is announced without moving focus. */}
<h2 aria-live='polite' className='text-sm font-semibold'>
{formatMonthHeading(year, month, locale)}
</h2>
<button type='button' onClick={nextMonth} aria-label='Next month'>
<ChevronRight aria-hidden className='h-4 w-4' />
</button>
</div>
<table role='grid' aria-labelledby='calendar-heading'>
<thead>
<tr>
{weekdayLabels.map((label, index) => (
// abbr carries the full name for screen readers; the visible text
// stays short enough for the column.
<th key={index} scope='col' abbr={fullWeekdayLabels[index]}>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{grid.map((week, weekIndex) => (
<tr key={weekIndex} role='row'>
{week.map((cell) => {
const isFocused = comparePlainDates(cell.date, focusedDate) === 0;
return (
<td key={toIsoDate(cell.date)} role='gridcell' aria-selected={cell.isSelected}>
<button
type='button'
// Exactly one focusable day: arrows navigate, Tab leaves.
tabIndex={isFocused ? 0 : -1}
ref={isFocused ? focusedCellRef : undefined}
aria-disabled={cell.isDisabled}
aria-current={cell.isToday ? 'date' : undefined}
// "15 March 2026", not "15" - a bare number is meaningless.
aria-label={formatFullDate(cell.date, locale)}
onClick={() => !cell.isDisabled && onSelect(cell.date)}
onMouseEnter={() => onHover(cell.date)}
className={cn(
!cell.isCurrentMonth && 'text-muted-foreground/50',
cell.isInRange && 'bg-yellow-400/15',
cell.isSelected && 'bg-yellow-400 text-slate-900',
cell.isDisabled && 'opacity-40 cursor-not-allowed',
)}>
{cell.date.day}
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
function onGridKeyDown(event: React.KeyboardEvent) {
const move = (days: number) => {
event.preventDefault();
const next = addDays(focusedDate, days);
setFocusedDate(next);
// Arrowing past a boundary must page the grid, or focus lands on a cell
// that is not rendered and the browser drops it to the body.
if (next.month !== month || next.year !== year) showMonthOf(next);
};
switch (event.key) {
case 'ArrowLeft': move(-1); break;
case 'ArrowRight': move(1); break;
case 'ArrowUp': move(-7); break;
case 'ArrowDown': move(7); break;
case 'Home':
event.preventDefault();
move(-((weekdayOf(focusedDate) - weekStartsOn + 7) % 7));
break;
case 'End':
event.preventDefault();
move(6 - ((weekdayOf(focusedDate) - weekStartsOn + 7) % 7));
break;
case 'PageUp':
event.preventDefault();
// Shift pages a year, matching the platform convention.
showMonthOf(addMonths(focusedDate, event.shiftKey ? -12 : -1));
break;
case 'PageDown':
event.preventDefault();
showMonthOf(addMonths(focusedDate, event.shiftKey ? 12 : 1));
break;
case 'Enter':
case ' ':
event.preventDefault();
if (!isDateDisabled(focusedDate, config)) onSelect(focusedDate);
break;
case 'Escape':
event.preventDefault();
closeAndReturnFocusToInput();
break;
}
}
Five requirements that make this genuinely usable:
- One tab stop. Only the focused day has
tabIndex={0}; the rest are-1. Forty-two tab stops between the user and the next form field is a broken form. - Crossing a month boundary pages the grid. This is the transition most implementations get wrong: focus moves to a date that is not rendered, so the browser drops focus to
document.bodyand the keyboard user is stranded. - Full accessible labels.
aria-label="15 March 2026"rather than the bare number, which is meaningless when read out of context. aria-disabled, not removal. A disabled date should still be reachable and announced as unavailable, so the user understands why they cannot pick it rather than finding a hole in the grid.- Escape returns focus to the input. Dismissing a popup that leaves focus nowhere is the most common focus-management bug in any overlay.
Focus must also be moved imperatively after the grid re-renders, since the focused cell may be a different element:
useEffect(() => {
// The focused day changed or the month paged, so the DOM node holding focus
// has been replaced. Re-apply it or focus is lost.
if (isOpen) focusedCellRef.current?.focus();
}, [focusedDate, month, year, isOpen]);
The grid pattern, roving tabindex and focus-return conventions are described in full in Accessibility Architecture; a calendar is the canonical two-dimensional case.
Common Interview Follow-Up Questions
"A user reports their booking is one day earlier than they selected. How do you debug it?"
Trace the value's kind at each boundary rather than looking at the calendar code. Find where the picked day is first converted, and you will almost always find a new Date(y, m, d).toISOString() or a TIMESTAMPTZ column holding what should be a DATE. Confirm it by reproducing with the browser timezone set to something east of UTC - if the bug appears there and not locally, it is a plain-date-as-instant error, and the fix is to change the type rather than to add a compensating offset. Adding hours to "fix" it is the most common wrong repair, because it moves the bug to whichever set of users you did not test.
"Should you build a date picker or use the native input?"
<input type="date"> is genuinely good and under-used: free localization, a familiar OS-native picker on mobile, correct keyboard behaviour, and it submits a plain yyyy-mm-dd string, which is exactly the right wire format. Use it whenever the requirements fit. Build a custom one when you need a range with a hover preview, per-date availability from a server, multi-month display, non-Gregorian calendars, or a design the native control cannot express. The honest answer in an interview is that the native input is the default and a custom picker needs a justification - and if you build one, it is react-aria, Radix or react-day-picker rather than from scratch, because the grid keyboard behaviour and focus management above are exactly what those libraries have already got right.
"Availability comes from the server - some dates are booked. How does that work?" Availability becomes async state, which changes the loading model rather than the grid logic. Fetch a window wider than the visible month so paging does not always block, key the cache by month so revisited months are instant, and prefetch the adjacent months on idle. While a month's availability is loading, render the grid with dates in an indeterminate state rather than as available - showing a date as selectable and then rejecting the click is worse than a brief skeleton. This is server state with a natural cache key, so it belongs in the query layer described in State Management Architecture, not in component state.
"How do you test a date picker?" Grid generation and the range reducer are pure and deserve the depth: months starting on each weekday under each week-start setting, leap years, February in a leap year with a Monday start, range swap, preview separation from committed value. Timezone correctness needs the process timezone forced to several zones - one behind UTC, one ahead, and one with a DST transition on a boundary date - which is the test that would have caught the off-by-one before it shipped. Parsing needs a table of locales with their field orders plus non-existent dates like 31 February. Keyboard behaviour is fully testable in jsdom: arrow across a month boundary and assert both the displayed month and that focus is on the right cell. The layering argument is in Testing Strategy.
"The picker is inside a modal near the bottom of the viewport and gets clipped."
That is positioning rather than dates, and it is why pickers are usually built on a positioning library. The popup must flip above the input when there is no room below, shift horizontally to stay in the viewport, escape any ancestor with overflow: hidden - usually by portalling to the document body - and still participate correctly in the modal's focus trap, which means the portal must be inside the trap's boundary or Escape and Tab behave inconsistently. On small screens the better answer is often not a popup at all but a full-screen sheet, which sidesteps clipping entirely and gives touch users larger targets.
Tradeoffs Table
| Option | Pros | Cons | When to Use |
|---|---|---|---|
Native <input type="date"> | Localization, mobile picker, keyboard and accessibility all free; submits a plain ISO date | No ranges, no custom availability, limited styling, inconsistent desktop UI | Any single-date field whose requirements it satisfies |
| Custom calendar component | Ranges, availability, multi-month, full design control | Grid keyboard behaviour, focus management and localization are all yours to get right | Ranges, booking flows, or a design the native control cannot express |
Plain date (yyyy-mm-dd) | Same calendar day for every viewer; no conversion, no off-by-one | Cannot express a moment; needs a separate time value when one is required | Birthdays, deadlines, check-in dates, invoice dates |
UTC instant (TIMESTAMPTZ) | Unambiguous moment; correct across zones and DST | Displayed calendar date varies by viewer, which surprises people | Meeting times, log entries, reminders, anything time-of-day sensitive |
| Fixed six-week grid | Constant popup height; no layout shift when paging months | One mostly-empty row for short months | Default, unless the design demands a tight grid |
| Hover-driven range preview only | Simple, feels natural with a mouse | Invisible to keyboard users; the range cannot be previewed without a pointer | Never on its own - always pair hover with focus-driven preview |
Where This Applies
A date picker is the smallest component that exercises Internationalization Architecture end to end: week start, field order, month names, calendar system and week numbering all vary, and every one of them fails silently for users outside the developer's locale. It is also the canonical grid widget from Accessibility Architecture, where a roving tabindex, a two-dimensional keyboard model and focus return on dismissal are the difference between usable and unusable. Server-driven availability makes it a query-cache consumer in the sense described by State Management Architecture.
Within this track, a date picker is almost always a field inside a larger flow - the validation, focus and persistence concerns of Multi-Step Form apply directly. Its popup shares the listbox-and-keyboard contract established in Autocomplete / Typeahead, and the plain-date-versus-instant distinction reappears anywhere a date crosses a wire, including the delivery estimates in Shopping Cart.
Advertisement
Why do date pickers produce timezone bugs, and what is the correct model?
Because a calendar date and a moment in time are different kinds of value, and JavaScript's Date represents only the second one. When a user picks the 15th of March they mean a whole calendar day, not an instant, but storing that as a Date pins it to an instant - midnight in whatever timezone the code happened to use. Send that to a server as an ISO string and it becomes the previous day for any user behind UTC, which is why a birthday saved in Los Angeles displays as the 14th in London. The correct model keeps three representations explicitly separate. A plain calendar date, stored as a year-month-day string with no time and no zone, is right for birthdays, deadlines and check-in dates. A true instant, stored as a UTC timestamp, is right for a meeting start or an audit log entry. And the displayed string is derived from one of those at render time using the viewer's locale and timezone, never stored. Almost every date bug is a value of one kind being handled as the other.
How do you generate a month grid, including the leading and trailing days?
You compute the offset from the configured week start to the weekday of the first of the month, subtract that many days to find the grid's first cell, and then emit a fixed number of consecutive days. The weekday offset must be taken modulo seven so that a Monday-start calendar handles a month beginning on Sunday without going negative. Emitting exactly six weeks - forty-two cells - rather than the minimum needed is usually the better choice, because a month that fits in five rows and one that needs six would otherwise change the popup's height and shift the layout when the user navigates between them. Every cell is tagged with whether it belongs to the displayed month, whether it is selected, whether it is disabled and whether it is today, and all of that is derived from plain year-month-day arithmetic rather than from timestamp math, which is what avoids daylight-saving errors around midnight.
What state does a range picker actually need?
Four things, and collapsing any of them causes a visible bug. The committed start and end dates are the value. A separate flag for which end of the range the next click sets, because after choosing a start the picker is in a distinctly different mode. And a hover date, which drives the preview highlight showing what the range would be if the user clicked now - preview state must be separate from the committed value or moving the mouse appears to change the selection. Two behaviours then need explicit handling. Clicking a date earlier than the start should normally swap the two rather than rejecting the click, since the user is expressing intent rather than making an error. And keyboard users have no hover, so the preview must follow the focused date instead, which means the same preview logic has to be driven by focus as well as by pointer position.
What has to be localized in a date picker beyond translating month names?
The week start day, which is Monday in most of Europe, Sunday in the United States, Japan and much of Latin America, and Saturday in parts of the Middle East - and getting it wrong silently shifts every column. The order of the day, month and year fields in any text input, since day-month-year and month-day-year are ambiguous for the first twelve days of every month. Weekday and month names in the correct grammatical form, which for some languages differs between a standalone label and a formatted date. First-week-of-year rules if week numbers are shown. Non-Gregorian calendar systems for locales that use them. And the fact that some dates simply do not exist in some zones, because a daylight-saving transition can remove an hour or, in rare cases, a jurisdiction skipping a day removes a whole calendar date. The platform Intl APIs supply most of this, and hand-written month name arrays are the usual sign that a picker is only correct in English.
What ARIA pattern does a calendar use and what does the keyboard have to support?
The grid pattern. The calendar is a role grid with each week a row and each day a gridcell containing the date, and it uses a single tab stop - only one day is focusable at a time, with arrow keys moving focus between days rather than Tab. Left and right move by a day, up and down by a week, Page Up and Page Down by a month, Home and End to the start and end of the week, and Enter or Space selects. Crossing a month boundary with an arrow key must navigate to the adjacent month and keep focus on the correct day, which is the transition most implementations get wrong. Each cell needs an accessible label containing the full date rather than just the number, since a bare 15 is meaningless out of context, disabled dates need aria-disabled rather than being removed from the tab order, and the currently selected date needs aria-selected. A live region announcing the displayed month as the user pages through completes it.