Design a date picker
A design-system date picker for single dates and date ranges, with a typed field and a popup calendar. A grid of numbers looks easy; the hard parts are storing a date without a time zone bug, typing dates in every locale, a full keyboard and screen reader model, the popup, range rules, and phones.
The question
Design a date picker for our design system. Teams need it for a date of birth on a sign-up form, a due date on a task, and check-in and check-out dates on a booking page that has some days already sold out.
Walk me through how you store the value, what the API looks like, how people type a date or pick one from a calendar, how it works with a keyboard and a screen reader, and how it behaves on a phone and in other languages.
Real-life examples: The check-in and check-out picker on Airbnb, The date of birth field on a bank sign-up form, The due date picker in Linear and Jira, The flight search dates on Google Flights, The event date field in Google Calendar
Requirements: what to ask before drawing a calendar
A date picker is a control for entering a calendar date. It usually has two parts: a date field where you can type, and a calendar that opens in a popup so you can pick a day with the mouse, a finger, or the keyboard. Most products need one, and date bugs are common. They are rarely in the drawing; they are in what a date means.
The browser has a built-in control, input type="date". Design systems still build their own because the native popup can't be styled, can't pick a range, can't mark individual days as sold out, and looks different in every browser. Part of a strong answer is knowing when the native one is the better choice anyway, and we will come back to that in the mobile section.
Start with questions. Each answer below changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| A date only, or a date and a time? | Date only by default. A time can be added for a few cases like meeting reminders. | With a date only, there's no time zone in the value at all. With a time, we must say which zone the time is in, and handle days with 23 or 25 hours. |
| One date, a range, or several dates? | Single and range. Several separate dates is a small extension. | A range needs a two-step selection, a hover preview, and rules like a minimum number of nights. |
| Which dates can't be picked? | A min and max, plus any day the product marks unavailable, like a sold-out night. | Unavailable days come from the server for booking pages, one month at a time, so the calendar needs a data source and a loading state. |
| Typing, picking, or both? | Both. A date of birth is faster to type; a holiday is easier to pick. | We need a parser that understands the user's locale, and validation messages for typed values. |
| Which languages and regions? | About 30 locales, including Arabic and Hebrew (right to left), and users whose week starts on Monday or Saturday. | Month and weekday names, field order like DD/MM/YYYY, the first day of the week, and mirrored arrow keys. |
| Which devices? | Desktop and phones, about half of booking traffic on phones. | A popup next to the field on desktop, a full-screen sheet or the native control on small screens. |
| Inside plain HTML forms? | Yes. Many forms post to the server without JavaScript state. | The component must put its value into the form, usually through a hidden input with a name. |
Functional requirements
- Pick a single date, or a start and end date, from a month calendar that pages by month and by year.
- Type a date in the field in the user's own format, with clear messages for dates that don't exist or are out of range.
- Respect
min,max, and a callback that marks single days unavailable. - Show today, the selected date or range, and a preview of the range while hovering or moving focus.
- Work fully with a keyboard and a screen reader, following the WAI-ARIA date picker pattern.
- Submit in a normal form, and work as a controlled component in React.
Non-functional requirements
- Correct in every time zone: a date picked in Tokyo is the same date when a colleague in Los Angeles opens it.
- Fast: opening the calendar under 50 ms on a mid-range phone, and moving focus with arrow keys well under one frame (16 ms).
- Small: the picker and its date code should add under about 15 KB gzipped to a page, because it sits on sign-up and checkout pages where every kilobyte delays the first interaction.
- Localized: names, order, first day of the week, numbering, and right-to-left layout come from the locale, not from hand-written tables.
- Accessible: meets WCAG 2.2 AA, including visible focus and targets of at least 24 by 24 CSS pixels.
Out of scope, and why
- A full scheduling calendar with events in a week view: that's a different problem (calendar week view).
- Recurring dates like "every second Tuesday": that's a rules editor, not a picker.
- Time zone pickers: we take the zone as an input when a time is involved, we don't build the list of zones.
Check your understanding · Exercise 1
Which of these answers change the design the most? Select all that apply.
What makes a date picker hard
Drawing a month is a weekend project. Shipping a date picker that works for a design system used by many teams is not. Five things drive every decision in the rest of this guide.
- A date is not a moment. "March 14" is a box on a wall calendar. It covers different hours in Tokyo and in Los Angeles. JavaScript's
Dateobject is a single moment in time, counted in milliseconds since 1970 in UTC. Using one to hold the other is the source of the most common date bug on the web: the date that moves by one day. - Every locale writes and reads dates differently.
3/4/2026is March 4 in the United States and April 3 in the United Kingdom. Weeks start on Sunday, Monday, or Saturday. Month names, digits, and the direction of the page all change. - It's a two-dimensional keyboard widget. A month is a grid of 7 columns and up to 6 rows. Arrow keys move by a day or a week, other keys jump by month or year, and screen reader users need to hear "Saturday, March 14, 2026", not "14".
- It lives in a popup. The calendar floats over the page, must stay on screen near edges, takes focus when it opens, and gives focus back when it closes. That's the whole dropdown problem again, with a bigger popup.
- Rules make some dates invalid, and the rules interact. A
min, amax, sold-out nights from the server, a minimum stay of 2 nights, a range that may not cross a sold-out night. Each one is easy alone; together they decide what each click means.
The first point matters most, so look at it closely. When a user in Tokyo picks Saturday, March 14, 2026, they mean the whole day.
If the code turns that into a Date at local midnight, it becomes the moment 2026-03-13T15:00:00Z, because Tokyo is 9 hours ahead of UTC. A colleague in Los Angeles who loads the same moment sees Friday, March 13 at 8 in the morning. Nobody changed the date. The type did.
Date is one point on the line, so midnight in Tokyo is still March 13 in Los Angeles. The string 2026-03-14 names the day itself and never moves.Keep that picture in mind. A calendar date is a label for a whole day, the same everywhere. A moment is a point on one shared timeline, which lands on different calendar days in different places.
Birthdays, due dates, check-in dates, and holidays are labels. Meeting start times, deadlines at 17:00 in Berlin, and log timestamps are moments. A good picker knows which one it's holding.
Check your understanding · Exercise 2
Which of these values are calendar dates (labels for a whole day) rather than moments? Select all that apply.
The value model: store a plain date, not a Date
The first decision is the type of the value: what the component holds, emits in onChange, and puts in the form. Everything else reads from it, so a wrong choice here leaks into every team that uses the picker.
The naive approach is a JavaScript Date, because it's built in and every library accepts one. Here is how it breaks in practice.
A user in Tokyo picks March 14. The picker creates new Date(2026, 2, 14) (months start at 0, which is its own trap), which is local midnight: the moment 2026-03-13T15:00:00.000Z. The form sends toISOString() to the server. The server stores the moment.
The user's manager in San Francisco opens the record, and her browser renders the moment in her zone: Friday, March 13. The bug report says "dates are off by one", and it only happens for some people, which makes it slow to find.
The reverse bug is just as common: a server sends the string 2026-03-14 and the client does new Date("2026-03-14"). The JavaScript spec says a date-only ISO string is parsed as UTC midnight, while a date-time string without an offset, like 2026-03-14T00:00, is parsed as local time. So in any zone west of UTC, such as all of the Americas, calling getDate() on the result gives 13. Two strings that look almost the same are parsed in two different zones.
// In a browser set to America/Los_Angeles (UTC-7 on this day):
new Date("2026-03-14").getDate(); // 13: date-only is parsed as UTC
new Date("2026-03-14T00:00").getDate(); // 14: date-time without offset is local
new Date(2026, 2, 14).getDate(); // 14: month index 2 means March
// A plain date never asks which zone it's in:
const value = "2026-03-14"; // what the picker stores and emits
Temporal.PlainDate.from(value).dayOfWeek; // 6 (Saturday), no zone involved
Decision What type should the picker's value be?
What we pick: The public API uses the ISO string YYYY-MM-DD: it's zone-free, serializable, form-ready, and matches the native input. Inside, the picker turns it into a plain date (Temporal.PlainDate where the browser has it, a tiny year-month-day helper otherwise) for math. Callers that want objects can wrap the string themselves; nobody can accidentally get a moment.
Temporal today
Temporal is the new date and time API in JavaScript, designed to replace Date. It has separate types for separate ideas: PlainDate for a day, PlainTime for a wall-clock time, ZonedDateTime for a moment in a named zone, and Instant for a raw moment. As of late 2026, Firefox ships it (since version 139) and Chrome and Edge ship it (since version 144), but Safari's stable releases don't yet.
So a picker that relies on it still needs a polyfill for Safari users, and a polyfill adds roughly 20 to 50 KB gzipped, depending on which one. Check the current state on MDN before you decide; it's moving.
That's why we keep Temporal behind a small internal adapter. The adapter exposes the handful of operations the picker needs: parse an ISO string, add days, months, or years, compare two dates, get the weekday, and get the number of days in a month. Today it can be about 60 lines of code built on Date.UTC, which is safe for pure day math because UTC never has a daylight saving change. When Safari ships Temporal, we swap the inside of the adapter and no team notices.
type PlainDate = { year: number; month: number; day: number }; // month is 1 to 12
const toUtc = (d: PlainDate) => Date.UTC(d.year, d.month - 1, d.day);
const fromUtc = (ms: number): PlainDate => {
const x = new Date(ms);
return { year: x.getUTCFullYear(), month: x.getUTCMonth() + 1, day: x.getUTCDate() };
};
export const addDays = (d: PlainDate, n: number) => fromUtc(toUtc(d) + n * 86_400_000);
export const compare = (a: PlainDate, b: PlainDate) => Math.sign(toUtc(a) - toUtc(b));
export const dayOfWeek = (d: PlainDate) => new Date(toUtc(d)).getUTCDay() || 7; // 1 Mon .. 7 Sun
export const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month, 0)).getUTCDate();
export function parseIso(s: string): PlainDate | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const d = { year: +m[1], month: +m[2], day: +m[3] };
return d.month >= 1 && d.month <= 12 && d.day >= 1 && d.day <= daysInMonth(d.year, d.month) ? d : null;
}
Check your understanding · Exercise 3
A user in New York picks June 1. The day shows as May 31 for some colleagues. Which code could cause this? Select all that apply.
When a time is included: zones and daylight saving
Some uses need a time too: "remind me on March 8 at 2:30", or "the event starts November 1 at 1:30 in New York". The moment a time joins the date, the value needs a time zone, and the picker inherits a problem that the date-only version never had: daylight saving time (DST), when clocks jump forward or back by an hour.
In New York in 2026, on Sunday, March 8, clocks jump from 2:00 straight to 3:00. Any time from 2:00 to 2:59 that day doesn't exist. On Sunday, November 1, clocks fall back from 2:00 to 1:00, so every time from 1:00 to 1:59 happens twice, once in daylight time (UTC-4) and once in standard time (UTC-5). Governments also change these rules from time to time, so they are data, not constants.
Europe changes on different dates (the last Sunday of March and of October), and many countries don't change at all.
The naive approach builds a Date with new Date(2026, 2, 8, 2, 30). A browser in New York silently picks 3:30 instead, and the user never learns their reminder moved. A related trap: code that sets a picked date to "midnight" can land on 1:00 in zones that change clocks at midnight, as Brazil did until it dropped daylight saving time in 2019. Day math done in local time, like adding 24 hours to get "tomorrow", is wrong twice a year in every DST zone, because those days are 23 and 25 hours long.
Decision How should a date with a time be stored when it's for a future event?
What we pick: For future events, store the wall time and the IANA zone name, and resolve to a moment late. For past events, a UTC moment is fine. The picker itself only produces wall time; it takes the zone as a prop (timeZone) and never assumes the browser's zone is the event's zone.
Times that don't exist, and times that happen twice
Temporal names the choice disambiguation. When turning a wall time into a moment, you pass compatible, earlier, later, or reject. The default, compatible, moves a missing time forward by the length of the gap (2:30 becomes 3:30) and picks the earlier of two repeated times, which matches what Date has always done.
A picker should not hide this. Our design: validate on blur, and if the time falls in a gap, show a message such as "2:30 doesn't exist on March 8 in New York because clocks move forward. Did you mean 3:30?"
If it falls in an overlap, show a small choice with both offsets: "1:30 EDT (first)" or "1:30 EST (second)". That's two extra states, rarely seen, and they turn a silent data bug into a clear question.
const wall = Temporal.PlainDateTime.from("2026-11-01T01:30");
const zone = "America/New_York";
const first = wall.toZonedDateTime(zone, { disambiguation: "earlier" }); // offset -04:00
const second = wall.toZonedDateTime(zone, { disambiguation: "later" }); // offset -05:00
const repeated = first.offset !== second.offset; // true: ask the user which one
// A missing time (March 8, 2:30) gives a gap instead of a repeat:
const gapWall = Temporal.PlainDateTime.from("2026-03-08T02:30");
gapWall.toZonedDateTime(zone, { disambiguation: "reject" }); // throws a RangeError
gapWall.toZonedDateTime(zone).toPlainDateTime().toString(); // "2026-03-08T03:30:00"
Check your understanding · Exercise 4
A user in New York picks November 1, 2026 at 1:30 for a reminder. What should a careful picker do? Select all that apply.
The component API: modes, rules, and forms
A design-system picker has many callers with different needs: a date of birth on a sign-up form, a due date in a task panel, check-in and check-out on a booking page. The API has to make the common case one line and the hard cases possible, without twenty boolean props. Read the component API lesson (component-api-design) for the general rules; here we apply them.
Decision How should single, range, and multiple selection be exposed?
What we pick: Separate components on a shared core. Styled components cover 95% of uses with correct accessibility built in, and the same hooks are exported for the rare team that needs custom markup. This is the shape React Aria and several large design systems settled on.
type IsoDate = string; // "YYYY-MM-DD"
type DateRange = { start: IsoDate; end: IsoDate };
type CommonProps = {
min?: IsoDate;
max?: IsoDate;
isDateUnavailable?: (date: IsoDate) => boolean; // must be fast and pure
locale?: string; // defaults to the app's locale
timeZone?: string; // IANA zone that owns "today", like a hotel's town
firstDayOfWeek?: 1 | 2 | 3 | 4 | 5 | 6 | 7; // override; defaults from locale
name?: string; // form field name
required?: boolean;
disabled?: boolean;
label: string; // visible label, required
description?: string; // help text, like "Day, month, year"
onVisibleMonthChange?: (month: string) => void; // "2026-03": fetch availability
isLoading?: boolean; // availability for the visible months is loading
};
type DatePickerProps = CommonProps & {
value?: IsoDate | null; // controlled
defaultValue?: IsoDate | null; // uncontrolled
onChange?: (value: IsoDate | null) => void;
};
type DateRangePickerProps = CommonProps & {
value?: DateRange | null;
defaultValue?: DateRange | null;
onChange?: (value: DateRange | null) => void; // only complete ranges
onStartChange?: (start: IsoDate) => void; // first pick, for example to fetch prices
minNights?: number;
maxNights?: number;
allowsUnavailableInside?: boolean; // default false
};
Controlled, uncontrolled, and partial ranges
The picker supports both modes React developers expect. Controlled: the parent passes value and onChange and owns the state, which is what form libraries need. Uncontrolled: the parent passes defaultValue (or nothing) and the picker keeps its own state, which is enough for a simple filter. Switching between the two during the component's life is a bug, and the picker warns about it in development, the same way a native React input does.
Ranges add a question: what happens between the first click and the second? The user has picked a start but no end yet. If onChange fires with { start, end: null }, every caller must handle a half-finished range, and a controlled parent that validates "end must be after start" will reject it and wipe the click.
So the half-finished range is the picker's own internal state, and onChange fires once, with a complete range. A parent that really wants to react to the first click (for example, to fetch prices for the possible end dates) gets a separate onStartChange event, not a broken value.
min, max, and isDateUnavailable
min and max are ISO strings, compared as plain dates. isDateUnavailable is a function the picker calls for every visible cell, so 42 calls per month on each render, 84 with two months. It must be pure and fast: a Set lookup, not a network call.
The booking page fetches sold-out nights per month (more on that in the architecture chapter), puts them in a Set, and passes (d) => soldOut.has(d). A function beats a list because some rules are patterns, like "no weekends" or "only Tuesdays", and a list of every weekend for ten years would be silly.
Working inside a plain form
Many forms are posted without JavaScript state, or read with new FormData(form). If the picker has a name, it renders an input type="hidden" with that name and the ISO value. A range with name="stay" renders two: stay.start and stay.end, or names the caller chooses.
The visible field shows the localized text ("14/03/2026") while the form carries the machine value ("2026-03-14"): the server never parses a localized date. For required, the picker reports validity with setCustomValidity on its own focusable field so the browser's built-in validation and form.reportValidity() work as usual. Read the form state lesson (form-state) for how this fits into larger forms.
Check your understanding · Exercise 5
A team builds a booking page where the end date must be at least 2 nights after the start. Which API choices make that safe? Select all that apply.
The input: typing a date in any locale
Clicking through a calendar is slow for dates far away. Picking a date of birth in 1987 from a month grid means dozens of clicks or a year dropdown. Typing 14/03/1987 takes two seconds.
So the field is not decoration around the calendar: for many users it's the main way in, and for keyboard and screen reader users it's often the easiest one. The GOV.UK Design System goes further and recommends plain typed fields, not a calendar, for dates people already know, like a date of birth.
Typing has three problems: the order of the parts (is 03/04/2026 March 4 or April 3?), the separators (slashes in the US and UK, dots in Germany as in 14.03.2026, hyphens elsewhere), and validity (31/02/2026 looks like a date and isn't one). A field that guesses wrong is worse than no field, because the wrong date looks fine until the hotel booking is for April.
Decision What should the typing part look like?
What we pick: One free text input with a strict, locale-aware parser and a visible format hint, plus a calendar button next to it. It's the most forgiving for paste, screen readers, and phones. Forms that ask for a date of birth use the three-input variant from the same package; teams that want spin buttons can build them on the exported hooks.
Getting the order from the locale
Don't keep a table of formats by hand. Intl.DateTimeFormat already knows them, and formatToParts tells you the order and the separators for any locale. Format a known date, read the order of the day, month, and year parts, and you have both the parser's order and the hint text. For en-US that's month, day, year; for en-GB, de-DE, and fr-FR it's day, month, year; for ja-JP and ko-KR it's year, month, day.
function fieldOrder(locale: string) {
const parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" })
.formatToParts(new Date(Date.UTC(2026, 2, 14)));
return parts.filter((p) => p.type === "day" || p.type === "month" || p.type === "year").map((p) => p.type);
}
fieldOrder("en-US"); // ["month", "day", "year"]
fieldOrder("de-DE"); // ["day", "month", "year"]
function parseTyped(text: string, order: string[]): string | null {
const iso = /^\d{4}-\d{2}-\d{2}$/.test(text.trim()) ? text.trim() : null; // pasted ISO
if (iso) return parseIso(iso) ? iso : null;
const nums = text.match(/\d+/g);
if (!nums || nums.length !== 3) return null; // "3/4" is rejected, not guessed
const get = (t: string) => Number(nums[order.indexOf(t)]);
const [y, m, d] = [get("year"), get("month"), get("day")];
if (y < 1000) return null; // ask for four-digit years
const out = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
return parseIso(out) ? out : null; // rejects 31/02 and month 13
}
Validation messages that help
Validate on blur and on submit, not on every keystroke: while someone types 1, 14, 14/0, the field is incomplete, not wrong. Each failure gets its own message, written in the user's language and naming the fix: "Enter a real date, like 14/03/2026" for text that can't be read, "February 2026 has only 28 days" for 31/02/2026, "Choose a date on or after 1 March 2026" for a date before min, and "14 March is sold out" for an unavailable date.
The message is linked to the field with aria-describedby and the field gets aria-invalid="true", so a screen reader reads it when focus returns. The input validation lesson (input-validation) covers the timing rules in depth.
Two more details. On phones, inputmode="numeric" brings up a number pad, but iOS's number pad has no slash key, so the parser also accepts eight digits with no separators, like 14032026. And the text box keeps whatever the user typed until blur; reformatting while they type moves the caret and feels like the field is fighting them.
Check your understanding · Exercise 6
A user in Germany types 3.4.26 into the field. What should happen? Select all that apply.
The calendar grid: weeks, locales, and names
The calendar shows one month (or two, for ranges) as a table: a header with the month and year, a row of weekday names, and rows of weeks. Building it is a small pure function from three inputs, the visible month, the first day of the week, and the rules, to a list of cells. Getting it right means handling how many rows a month needs, which day starts the week, and what everything is called in the user's language.
Months need between 4 and 6 rows. February 2026 starts on a Sunday and has 28 days, so in the United States, where weeks start on Sunday, it fits in exactly 4 rows. In the UK, where weeks start on Monday, the same month needs 5, because February 1 sits alone in the last column of the first row.
August 2026 starts on a Saturday and has 31 days, so it needs 6 rows in both. If the grid grows and shrinks, the popup changes height as you page through months, the buttons under it jump, and a popup placed above the field moves away from it.
Decision How many rows should every month show?
What we pick: Always 6 rows. A stable height matters more than a few pixels, especially inside a popup. Outside days show their number dimmed and are not focusable, so the keyboard model stays inside one month, and clicking one moves to its month. Two-month range views use the same 42-cell grid twice.
type Cell = { date: IsoDate; inMonth: boolean; disabled: boolean; isToday: boolean };
function monthGrid(year: number, month: number, firstDay: number, rules: Rules, today: IsoDate): Cell[] {
const first: PlainDate = { year, month, day: 1 };
const lead = (dayOfWeek(first) - firstDay + 7) % 7; // empty slots before the 1st
const start = addDays(first, -lead);
return Array.from({ length: 42 }, (_, i) => {
const d = addDays(start, i);
const date = toIso(d);
return {
date,
inMonth: d.month === month,
disabled: rules.isOutOfRange(date) || rules.isUnavailable(date),
isToday: date === today,
};
});
}
Which day starts the week
Sunday in the United States, Canada, and Japan; Monday in most of Europe; Saturday in Egypt and several other countries in the Middle East. The browser can tell you: new Intl.Locale("en-GB").getWeekInfo() returns an object whose firstDay is 1 (Monday, using 1 for Monday through 7 for Sunday), plus weekend days and minimalDays.
Support is the catch. Chrome (from version 130) and Safari (from version 17) have the method, older versions of both exposed a weekInfo property instead, and Firefox only added the method in version 153, long after the others. So the picker feature-detects the method, then the property, then falls back to a small table keyed by region, with Monday as the default. Teams can also pass firstDayOfWeek directly, because some users set a personal preference that differs from their region.
Note that the first day depends on the region, not the language. en-US starts on Sunday, en-GB on Monday: same language, different week. A locale with no region, like plain en, needs a sensible default; Intl.Locale can add the likely region with maximize().
Month names, weekday names, digits, and week numbers
- Header:
new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" })gives "March 2026", "mars 2026", or "2026年3月". Don't glue a translated month name to a number yourself: word order and grammar differ. - Weekday row: use
weekday: "short"for the visible label, becausenarrowgives "S M T W T F S" in English, with two S and two T. Give each column header the full name ("Saturday") for screen readers. - Digits: format day numbers with
Intl, notString(day). Some locales, like Arabic in Egypt, use Arabic-Indic digits by default. - Week numbers: optional, off by default, on for many European business apps.
Intldoesn't compute them. ISO 8601 weeks start on Monday and week 1 is the week with the year's first Thursday; the US convention differs.minimalDaysfrom week info tells you which rule a region uses. - Other calendars: some locales default to a non-Gregorian calendar in
Intl. Thai formatting, for example, shows the Buddhist year 2569 for 2026. Our grid is Gregorian, so we format withcalendar: "gregory"to keep the header and the grid consistent, and list full support for other calendars as a known gap (React Aria shows it can be done with a calendar-aware date library).
Check your understanding · Exercise 7
A user's locale is en-GB. Which statements about their calendar are true? Select all that apply.
Keyboard and screen readers
A calendar is a two-dimensional widget, so it can't be navigated with Tab alone: 42 tab stops per month would be unusable. The WAI-ARIA Authoring Practices Guide (APG) has a date picker dialog example that sets the expected model. Screen reader users who have used one accessible calendar expect the next one to work the same way, so we follow it instead of inventing keys. The lessons on ARIA patterns (aria-patterns) and keyboard navigation (keyboard-navigation) cover the general rules; this chapter is the calendar's version.
| Key | What it does | Edge case to handle |
|---|---|---|
| Right / Left arrow | Next / previous day | Crossing into another month pages the grid. In right-to-left layouts the two are swapped. |
| Down / Up arrow | Same weekday next / previous week | Moves 7 days, possibly into the next month. |
| Home / End | First / last day of the current week | "First" depends on the locale's first day of the week. |
| Page Down / Page Up | Same day next / previous month | January 31 plus one month is February 28 (or 29): clamp to the month's length. |
| Shift + Page Down / Up | Same day next / previous year | February 29, 2028 plus one year is February 28, 2029. |
| Enter or Space | Select the focused day | In a range, the first press sets the start, the second the end. |
| Escape | Close without changing the value | Focus returns to the button or field that opened it. |
| Tab / Shift + Tab | Move between the month buttons, the grid, and any footer buttons | The grid is one tab stop. |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
One tab stop: roving tabindex
The grid is one tab stop because exactly one day button has tabindex="0" and every other day has tabindex="-1". Arrow keys move that 0 to the new day and call .focus() on it. This is a roving tabindex, the same technique as tabs and toolbars (the tabs guide uses it too). When focus enters the grid, it lands on the selected date, or today if nothing is selected, or the first available day of the month if today isn't in view.
The alternative, aria-activedescendant, keeps real focus on the grid and points at a cell by id. It works, but some screen readers handle it less reliably inside tables, and the focused cell would not scroll into view by itself. Real focus on a real button is simpler.
Decision Should keyboard focus move onto unavailable dates?
What we pick: Focus them but don't select them, with one limit: focus never goes before min or after max, where it stops at the boundary. Predictable movement and knowing what's blocked matter more than saving a few key presses. Note the use of aria-disabled rather than the disabled attribute: a disabled button can't receive focus at all.
What a screen reader should hear
- The popup is a dialog labeled by its purpose: "Choose check-in date". The month heading has an id, and the grid has
role="grid"andaria-labelledbypointing at it, so entering the grid announces "March 2026, grid". - Each day's accessible name is the full date, built with
Intl.DateTimeFormatanddateStyle: "full": "Saturday, March 14, 2026", not "14". The visible text stays "14"; the full name goes inaria-label. - State goes in attributes, not words in the label where an attribute exists:
aria-selected="true"on the selected day'sgridcell(buttons don't support it, so eachtdcell holds the button),aria-current="date"on today,aria-disabled="true"on blocked days. Reasons a day is blocked ("sold out") go in the label or a description, because there's no attribute for why. - When the month changes, the new month name is announced. The heading sits in a polite live region (
aria-live="polite"), as in the APG example, so Page Down announces "April 2026" before the focused day. - The field's description tells users there's a calendar and how to type: "Format: DD/MM/YYYY. Press the calendar button to pick a date."
- The calendar button's name includes the chosen date, as in the APG example: "Change date, Saturday, March 14, 2026". When focus returns to it after a pick, the user hears the result.
Check your understanding · Exercise 8
Focus is on Saturday, January 31, 2026 in a Sunday-first calendar. Which results are correct? Select all that apply.
The popup: dialog, focus, and placement
The calendar opens from a button next to the field. Three questions decide how that popup behaves: what kind of thing it is for assistive technology, where focus goes when it opens and closes, and where it appears on screen. The dropdown select guide solves the same layering and placement problem for a list; the calendar reuses that engine, so this chapter focuses on what's different.
Decision What kind of popup should the calendar be?
What we pick: A non-modal dialog in a popover on desktop, and the inline calendar where the page is built around dates (the booking page uses the standalone RangeCalendar). On phones, the popup becomes a full-screen modal sheet (see the mobile section). Tabbing past the last control in the popover closes it and moves on to the next field, which matches what users expect from a dropdown.
The APG has a second date picker example worth knowing: the date picker combobox. There, the text field itself has role="combobox", aria-haspopup="dialog", and aria-expanded, and Down Arrow or Alt+Down Arrow opens the calendar. We keep the dialog example's simpler model, a plain text field plus a separate calendar button, because it needs fewer ARIA roles to get right. The field can still open the calendar on Alt+Down Arrow as a shortcut.
Focus on open, and back on close
Focus moves into the grid when the calendar opens, onto the selected date (or today, or the first available day). It doesn't go to the "previous month" button, even though that's first in the tab order, because the grid is what the user came for.
When the calendar closes by Escape, by a click outside, or by picking a date, focus goes back to the calendar button, so a keyboard user continues from where they were. The focus management lesson (focus-management) explains why losing focus to the body is one of the worst things a popup can do.
One case needs care: if the user clicks outside on another input, focus should stay on what they clicked, not jump back to the calendar button. So "return focus" only happens when focus would otherwise be lost, meaning it's on the body or inside the closing popover. The Popover API's light dismiss closes the popover on that outside click; our toggle event handler checks where focus is before restoring it.
Placement: below, flip above, stay on screen
The calendar is about 300 pixels wide and 340 tall, bigger than most dropdown lists, so it hits screen edges more often. Default placement is below the field, aligned to its start edge (left in left-to-right, right in right-to-left). If there's not enough room below, it flips above; if it would overflow sideways, it shifts in. Because the grid always has 6 rows, its height is stable, so a flipped popup stays attached to the field when you page months.
.date-field { anchor-name: --date-field; }
.calendar-popover {
position-anchor: --date-field;
position-area: block-end span-inline-end; /* below, starting at the field's start edge */
position-try-fallbacks: flip-block, flip-inline;
inset: auto; /* undo the popover's default centering */
margin: 4px 0;
}
/* Without anchor positioning, a script measures the field and sets top and left inline. */
@supports not (anchor-name: --a) {
.calendar-popover { margin: 0; }
}
The Popover API is supported in all major browsers since 2024 (Baseline), so we use it everywhere for the top layer and light dismiss. CSS anchor positioning is newer: it only reached every major engine in 2026, so we feature-detect it with @supports and fall back to a small script positioner (Floating UI or our own) for older browsers. The script listens to scroll and resize on every scrolling ancestor and recomputes, which is exactly the work anchor positioning removes.
Check your understanding · Exercise 9
A keyboard user opens the calendar, presses Page Down twice, and presses Escape. What should happen? Select all that apply.
Range selection: two clicks and many rules
A range is picked with two actions: the first sets the start, the second sets the end. Between them, the picker is in a half-finished state that the single picker never has. That state is where every range rule lives, so we model it explicitly as a small state machine instead of a pile of booleans like isSelectingEnd and hasStart.
The machine has three states. Idle: a complete range (or nothing) is shown. Start picked: the user has chosen a start, and every move of the pointer or the keyboard focus updates a preview end. Check rules: the second choice is checked against the rules and, if valid, onChange fires with the complete range and the machine returns to idle.
Escape in the start-picked state drops the half range and goes back to the last committed value (a second Escape closes the popup), so a user can't lose a good range by starting a new one by accident.
The preview, for mouse and keyboard
While the start is picked, the days between the start and the hovered day are tinted, so the user sees the range before committing it. The preview follows keyboard focus too, not only the mouse: a keyboard user moving with arrows sees and hears the same thing. Each day's accessible name adds the preview result when it matters, like "Thursday, March 12, 2026, check-out, 2 nights", and after the second choice a polite live region announces "March 10 to March 14, 4 nights".
Decision The user picked March 10 as the start, then clicks March 6. What should happen?
What we pick: Restart from the new day by default, as the Airbnb guide also does for check-in dates. Report filters can opt into swapping with a prop, because for them any order is valid and there are no stay rules.
Rules that shape a range
- Minimum and maximum length. With
minNights={2}and a start of March 10, March 11 is disabled as an end, announced as "Minimum stay is 2 nights". WithmaxNights={28}, days past April 7 are disabled. - Unavailable days inside the range. By default a range can't contain a blocked day. Once the start is picked, the first blocked day after it caps the range, and every later day is disabled as an end. Some products allow it (a vacation-days request across a public holiday), so
allowsUnavailableInsideturns the cap off. - Nights, not days. For stays, a range from March 10 to March 14 uses the nights of the 10th to the 13th. The 14th only needs to be free as a check-out day. That's a booking rule the page passes in through
isDateUnavailableand a separate check-out rule; the picker's job is to ask per day and display the answer. - Two months side by side. On wide screens, ranges show two months, so a stay crossing a month end is visible at once. Paging moves both by one month. On narrow screens it's one month at a time, or a vertical scroll of months in the full-screen sheet.
- Today. The first selectable day is "today" in the time zone that owns the dates (a hotel's town, for example), passed as a prop, not today on the user's laptop.
function canEndAt(start: IsoDate, end: IsoDate, r: RangeRules): string | null {
const n = nightsBetween(start, end);
if (n < 1) return "Pick a day after check-in";
if (r.minNights && n < r.minNights) return `Minimum stay is ${r.minNights} nights`;
if (r.maxNights && n > r.maxNights) return `Maximum stay is ${r.maxNights} nights`;
if (!r.allowsUnavailableInside) {
for (let d = start; d < end; d = addDaysIso(d, 1)) {
if (r.isNightUnavailable(d)) return "Includes an unavailable night";
}
}
return null; // valid end
}
The loop looks slow, but it's bounded by maxNights and runs only while a start is picked. For 84 visible cells and a 28-night maximum, that's at most a few thousand Set lookups, well under a millisecond. Even so, the grid computes it once per start (one pass that finds the first blocked night) rather than once per cell, as the performance section shows. Comparing ISO strings with < works because YYYY-MM-DD sorts as text in date order.
Check your understanding · Exercise 10
Check-in is March 11, minNights is 2, and the night of March 14 is booked. Which days can be chosen as check-out? Select all that apply.
Architecture and data flow
With the decisions made, here are the parts and the lines between them. The main idea: pure logic (date math, the grid model, the range rules) sits in plain functions with no React and no DOM, state sits in hooks, and the visible components only render and forward events. That split is what lets the same core power the field, the popover picker, the inline booking calendar, and a team's custom markup.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
dateAdapter | Parse and format ISO strings, add days, months, years, compare, weekday, days in month. UTC only. | The only code that touches Date (or Temporal). Swapping to Temporal later changes one file. |
localeInfo | Cached Intl.DateTimeFormat instances, field order, first day of the week, digits. | Creating Intl formatters is slow compared to using them; one cache per locale serves every picker on the page. |
monthGrid | Pure function: month, first day, rules, today to 42 cells. | No state, so it's trivial to test and memoize. |
useDatePickerState | Value (controlled or not), open state, focused date, visible month. | One owner for state, shared by the field and the calendar. |
useRangeMachine | Idle, start picked, check rules; preview end; rule checks. | Range rules in one tested place, not spread over click handlers. |
DateField | Text input, hint, parser, validation message, hidden form input. | Usable alone, for forms that never need a calendar. |
CalendarPopover | Popover or sheet shell, placement, focus in and focus back. | Same shell as the select; layering bugs get fixed once. |
Calendar / RangeCalendar | Header, month buttons, one or two CalendarGrids, live region. | Usable inline on a booking page without the field or popup. |
DayCell | One button: label, state attributes, styling hooks. | Memoized, so it re-renders only when its own state changes. |
Decision Who fetches sold-out days from the server?
What we pick: The page owns the data, the picker owns the interaction. The picker tells the page which months are visible and renders whatever rules it's given. This keeps network code out of the design system, which is used by products with very different APIs.
Follow the data on a booking page. The page loads the listing with its time zone. The guest opens the calendar; the picker reports that March and April are visible. The page requests availability for those months and one month ahead, so paging feels instant, and caches each month for about a minute because sold-out nights change.
The response, a list of nights and their minimum stays, becomes a Set of blocked days and a lookup the picker calls per cell. When the guest commits a range, onChange gives the page { start: "2026-03-11", end: "2026-03-14" }; the page updates the URL and the price box, and the form posts the same two ISO strings. The server checks every rule again, because the client's view of availability can be a minute old.
The failure path matters too. If the availability request fails, the grid shows a short message inside the popover, "Couldn't load availability. Try again", with a retry button. The grid's aria-busy goes back to false and the live region reads the message, instead of a spinner that never ends.
Days stay selectable. The booking request is the real check, and blocking the whole calendar because a hint failed would stop people from booking at all.
The state, in one place
type PickerState = {
value: IsoDate | null; // committed value (or from props when controlled)
draftText: string; // what's in the text box right now
error: FieldError | null; // "invalid" | "outOfRange" | "unavailable"
isOpen: boolean;
visibleMonth: string; // "2026-03": first visible month
focusedDate: IsoDate; // owner of tabindex="0" in the grid
};
type RangeState =
| { kind: "idle"; value: DateRange | null }
| { kind: "startPicked"; start: IsoDate; preview: IsoDate | null; previous: DateRange | null };
Where each piece lives follows from who needs it. The committed value lives in the parent when controlled (often in a form library or the URL on a search page, as in the Airbnb guide). Draft text, the error, open state, visible month, focused date, and the range machine are UI state inside the picker: nobody outside needs them, and lifting them up would make every keystroke re-render the parent form. Availability lives in the page's server cache.
Check your understanding · Exercise 11
Which of these belong inside the picker's own state rather than the parent's? Select all that apply.
Phones, right-to-left, speed, size, and tests
The design works on a desktop in English. This chapter covers what changes on a phone, in Arabic or Hebrew, on a slow device, in the bundle, and in CI, where date bugs often hide.
Phones: the native picker or our own sheet
Decision What should the picker be on a phone?
What we pick: Both, by use. Simple single dates, like a due date, use the native input on phones, because there it's usually the most accessible option and the cheapest. Ranges and anything with blocked days use our sheet, because the native control can't express those rules. The value is the same ISO string either way, so the page doesn't care which one rendered.
Right-to-left
In Arabic and Hebrew, the whole calendar mirrors: the first day of the week is on the right, the "next month" arrow points left and sits on the left, and the popover aligns to the field's right edge. Using logical CSS properties (margin-inline-start, inset-inline-end) and a grid that follows dir gets most of this for free.
The keyboard must mirror too: in right-to-left, Left arrow means the next day. Read the direction from the element with getComputedStyle(el).direction rather than the locale, so a Hebrew page with an English widget still behaves. The rtl-layouts lesson covers the layout side.
Performance: don't re-render 84 cells on every hover
The naive range calendar keeps hoveredDate in state at the top and passes it down, so every pointer move re-renders all 84 cells of a two-month view. On a mid-range phone that's a few milliseconds each time, and pointer moves can arrive about once per frame, so the preview stutters.
Three fixes, in order: memoize the grid model per month and first day with useMemo, so moving the pointer doesn't rebuild cells; wrap DayCell in React.memo and pass it small derived booleans (inPreview, isRangeEnd), so only cells whose state actually changes re-render, usually 2 to 10 per move; and compute range limits once per start (the first blocked night and the min and max end) instead of running the rule loop per cell.
Pointer events are handled once on the grid (event delegation), reading the day from a data-date attribute. Creating Intl.DateTimeFormat objects is far slower than calling format, so the 84 accessible labels reuse one cached formatter per locale.
Bundle size
| Option | Rough gzipped cost | Notes |
|---|---|---|
Our own adapter on Date.UTC and Intl | About 1 to 2 KB | Only the dozen operations we need. Formatting and locale data come from the browser. |
| date-fns | A few KB for what a picker imports | Tree-shakable functions; cost grows with each function and locale imported. |
| Day.js | A few KB core, plus plugins | Small, but works on Date objects, so the moment-versus-day trap is still yours to avoid. |
| Luxon | Roughly 20 KB | Zone-aware and built on Intl; more than a date-only picker needs. |
| A Temporal polyfill | Roughly 20 KB to 50 KB, by polyfill | The right model; only worth it if the app already ships it or targets browsers that have Temporal. |
This is why the adapter exists. The picker's job needs plain day math and Intl, both of which are tiny or free. Loading a large date library on a checkout page for that delays the page's first interaction for every user, while most of them never open the calendar. The calendar itself (grid, popover, range machine) is also loaded on demand: the field renders immediately, and the calendar code is fetched on first focus of the field or hover of the button, before the click lands.
Testing dates without flaky tests
- Freeze the clock. "Today" changes every day, so tests that render the current month break on the 1st or on December 31. Use fake timers with a fixed system time (
vi.setSystemTimein Vitest,jest.setSystemTimein Jest) and pick risky days on purpose: January 31, February 29, 2028, and December 31. - Run in several time zones. Set the
TZenvironment variable for Node test runs, for exampleTZ=America/Los_Angeles,TZ=Asia/Tokyo, andTZ=Pacific/Kiritimati(UTC+14), and run the date tests in each in CI. The off-by-one bug only shows up in zones on one side of UTC, so a team in one office never sees it. - Browser tests with a zone and locale. Playwright's
timezoneIdandlocaleoptions run the real component in, say,ar-EGin Cairo anden-USin Los Angeles, catching first-day-of-week and right-to-left bugs. - Unit tests on the pure parts. The adapter,
monthGrid, the parser, andcanEndAtare pure functions, so most cases are fast table tests: every month of a year for row counts, every locale order for parsing, DST dates for time handling. - Accessibility checks. Automated rules (axe) catch missing labels; keyboard tests drive Page Down and Shift+Page Down across month ends; and a manual screen reader pass covers what tools can't hear.
Check your understanding · Exercise 12
Which steps catch the "date moves by one day" bug before users do? Select all that apply.
Running it in the interview
A date picker question is a test of whether you've shipped one. Interviewers listen for the value model in the first minutes, a real keyboard model, and locale awareness. Drawing a pretty grid for 20 minutes is the most common way to run out of time with none of those said.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: date or date and time, single or range, blocked days, typing, locales, phones, forms. | "A date of birth and a check-in date are calendar days, not moments. I'll store YYYY-MM-DD strings." |
| 5 to 10 | Draw the wireframe: field, calendar button, popover with header and 6-row grid. | "Six rows always, so the popup's height never jumps." |
| 10 to 18 | Write the props: value, onChange, min, max, isDateUnavailable, name; separate range component. | "onChange only fires with complete ranges; the half range is internal state." |
| 18 to 28 | Keyboard and screen reader model; popover focus in and back. | "Roving tabindex, APG keys, full date names, month changes announced." |
| 28 to 38 | Range rules and the state machine; architecture and data flow for availability. | "The page fetches sold-out nights per month; the picker asks a pure lookup per cell." |
| 38 to 45 | Locales, RTL, phones, performance, bundle, testing in several zones. | "First day of week from getWeekInfo with a fallback; TZ matrix in CI." |
If the interviewer pushes on one area, follow them there and drop the later rows. Depth on the value model or the keyboard model is worth more than covering bundle size.
Follow-up questions and strong answers
Think first
"Now add a time. What changes?"
The value needs a zone. For future events I'd store the wall time and an IANA zone name, and resolve to a moment late, because zone rules can change before the event.
The time field must handle DST: a time that doesn't exist (2:30 on March 8, 2026 in New York) gets a message suggesting 3:30, and a repeated time (1:30 on November 1) asks which offset.
Date-only users are unaffected; the time picker is a separate field next to the date field.
Think first
"Why not just send toISOString() to the server?"
Because that's a moment in UTC. Local midnight in Tokyo is the previous day in UTC, so the server stores the wrong day, and every viewer west of the user sees it shifted.
A calendar date should go to the server as 2026-03-14 and into a DATE column. No conversion, no zone.
Think first
"Why not use the native date input everywhere?"
It's the best choice for plain single dates on phones: usually accessible there, localized by the OS, free.
It can't do ranges, blocked individual days, reasons, or a consistent look, and its display format follows the device, not the page. So the design system uses it where those don't matter and our calendar where they do, with the same ISO value either way.
Check your understanding · Exercise 13
You have 5 minutes left and haven't discussed accessibility. What's the best use of them? Select all that apply.
Summary
- A calendar date is a label for a whole day, not a moment. Store and send it as a
YYYY-MM-DDstring, never as aDateat midnight. new Date("2026-03-14")is UTC midnight, so it shows the 13th west of UTC. Keep all day math in one UTC-only adapter, ready to swap for Temporal.- When a time is added, store wall time plus an IANA zone for future events, and handle times that don't exist or happen twice on DST days.
- Separate single and range components on a shared core;
onChangeemits only complete ranges;isDateUnavailableis a pure lookup; a hidden input carries the ISO value into forms. - Take the field order, names, digits, and first day of the week from
Intl, with feature detection and fallbacks forgetWeekInfo. - Always render 6 rows, follow the APG keyboard model with roving tabindex, give each day its full date as its name, and announce month changes.
- The popup is a non-modal dialog in a popover with anchor positioning and a fallback; focus goes into the grid on open and back to the button on close.
- Model ranges as a small state machine with a preview, restart on an earlier click, and rules for minimum stays and blocked nights.
- Keep date libraries out of the bundle, memoize the grid and cells, and run date tests in several time zones with a frozen clock.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA APG: Date Picker Dialog Example The keyboard model and roles this guide follows.
- WAI-ARIA APG: Grid Pattern Roving focus and keys in two-dimensional widgets.
- MDN: Temporal PlainDate, ZonedDateTime, and current browser support.
- MDN: Date.parse and the date time string format Why date-only strings are parsed as UTC.
- MDN: Intl.Locale.prototype.getWeekInfo() First day of the week by region, and its support caveats.
- MDN: Intl.DateTimeFormat.prototype.formatToParts() Field order and separators for any locale.
- MDN: input type="date" The native control, its value format, and its limits.
- MDN: Popover API Top layer and light dismiss for the calendar popup.
- WAI-ARIA APG: Date Picker Combobox Example The editable-field variant that opens the same calendar dialog.
- Hassell Inclusion: Is input type="date" ready for use in accessible websites? Accessibility tests of the native date input across browsers and screen readers.
- GOV.UK Design System: Date input Research-backed advice on typed dates and when not to use a calendar.
- React Aria: DatePicker A production design of segmented fields, calendars, and locale support.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it.