cf.completefrontend
My progress
SYSTEM DESIGN · #13

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the date range picker on a booking page. At the top, a text field shows 11/03/2026 to 14/03/2026 with a calendar button beside it and a hint below: Format DD/MM/YYYY. Under it, the open popover has a previous month button, the heading March 2026, and a next month button, then a weekday row from Monday to Sunday, then a grid of weeks. The 9th is today, the 11th and 14th are the range ends, the 12th and 13th are inside the range, and the 15th is sold out. A note lists the parts: month header, weekday row, 6-row grid, live region.Booking page: dates11/03/2026 to 14/03/2026calFormat: DD/MM/YYYY<March 2026>MoTuWeThFrSaSu23242526272812345678910111213141516171819202122month headerweekday row6-row gridlive regiontoday: 9sold out: 15
The field and the calendar are two ways into one value. The popover always has a header with paging, a weekday row that follows the locale (Monday first here), and a grid of 6 weeks; only 4 are drawn.

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.

  1. 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 Date object 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.
  2. Every locale writes and reads dates differently. 3/4/2026 is 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.
  3. 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".
  4. 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.
  5. Rules make some dates invalid, and the rules interact. A min, a max, 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.

A timeline in UTC from noon on March 13 to noon on March 15, 2026, with ticks at midnight UTC on March 14 and March 15. Three bars show March 14 as a whole day in UTC, in Tokyo, and in Los Angeles: Tokyo's March 14 starts 9 hours before UTC's, and Los Angeles's starts 7 hours after. A dashed line marks the moment new Date(2026, 2, 14) created in Tokyo, which is 15:00 UTC on March 13. In Los Angeles, that same moment falls on March 13 at 8:00.Mar 14, 0:00ZMar 15, 0:00ZUTCMarch 14 in UTCTokyoMarch 14 in TokyoLAMar 13 in LAMarch 14 in LAnew Date(2026, 2, 14) in Tokyo = 13th, 15:00Z8:00 on the 13th in LA
A calendar day is a different stretch of time in every zone. A 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.

ts
// 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
The same day, parsed three ways. Only the plain string and the plain date can't drift.
Stored as
Picked in
A user in Tokyo picks Saturday, March 14, 2026. Stored as Date at local midnight, the value is 2026-03-13T15:00:00.000Z. Viewers see: Tokyo Sat Mar 14, Kolkata Fri Mar 13, London Fri Mar 13, LA Fri Mar 13, Kiritimati Sat Mar 14, Pago Pago Fri Mar 13.picked in TokyoSat Mar 142026-03-13T15:00:00.000Za moment in UTCwhat each viewer's local date showsMar 14Tokyo00:00Mar 13Kolkata20:30Mar 13London15:00Mar 13LA08:00Mar 14Kiritimati05:00Mar 13Pago Pago04:004 of 6 viewers see the wrong day
Picked in Tokyo and stored as a moment: 4 of 6 viewers see the wrong day.

Decision What type should the picker's value be?

A JavaScript Date

Hold a Date at local midnight for the picked day.

Emit it in onChange; callers format or serialize it.

Pros

  • Built in, and every date library accepts one.
  • Familiar to every developer.

Cons

  • It's a moment, not a day, so it shifts when serialized and read in another zone.
  • Mutable: setDate() changes it in place, so a parent and the picker can share and corrupt one object.
  • Zero-based months and the UTC parsing rule invite bugs at every call site.

An ISO date string, YYYY-MM-DD

The value is a string like 2026-03-14; a range is two of them.

Parse into year, month, and day numbers only inside the picker for math.

Pros

  • Has no zone, so it can't drift. Sorts correctly as text.
  • Goes straight into a form field, JSON, a URL, and a database DATE column.
  • Same format as the value of a native input type="date", so the two are swappable.

Cons

  • A string can be malformed, so the API must validate at the edge.
  • Callers need a helper to do math on it.

A plain date object (Temporal.PlainDate or a library equivalent)

Use Temporal.PlainDate, or a small object like CalendarDate from @internationalized/date, as the value.

Math methods like add({ months: 1 }) and compare come with it.

Pros

  • The right concept: a day with no time and no zone.
  • Immutable, with correct month and leap-year math built in.

Cons

  • Temporal isn't in every browser yet, so it needs a polyfill.
  • Objects don't go into a form or JSON on their own; you still serialize to the ISO string.

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.

ts
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;
}
A zone-free date adapter. Every getter is the UTC one, so the user's zone never enters the math.

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.

Wall clock hours in New York on the two daylight saving days of 2026. On Sunday, March 8, the clock goes 0:00 and 1:00 at UTC-5, then jumps to 3:00 and 4:00 at UTC-4: times from 2:00 to 2:59 don't exist. On Sunday, November 1, the clock goes 0:00 and 1:00 at UTC-4, then 1:00 again at UTC-5, then 2:00 and 3:00: times from 1:00 to 1:59 happen twice.Sun Mar 8, 2026, New York: clocks jump forward0:00UTC-51:00UTC-52:00-2:59doesn't exist3:00UTC-44:00UTC-4Sun Nov 1, 2026, New York: clocks fall back0:00UTC-41:00UTC-4 (1st)1:00 againUTC-5 (2nd)2:00UTC-53:00UTC-5
A date with a time needs a zone, and the zone has days with a missing hour and days with a repeated hour. A picker should ask about both instead of silently moving the time.

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?

A UTC moment only

Convert the picked wall time to UTC right away and store 2026-11-01T05:30:00Z.

Pros

  • One unambiguous number, easy to sort and compare.
  • Fine for things that already happened, like logs and payments.

Cons

  • Loses the user's intent. If the zone's rules change before the event, the stored moment now means a different wall time.
  • You can no longer show "1:30 in New York" without guessing the zone.

Wall time plus an IANA zone name

Store the local date and time and the zone: 2026-11-01T01:30 and America/New_York, plus which of two repeated times was meant if needed.

Compute the UTC moment when you need it, with the zone rules current at that time.

Pros

  • Keeps what the user meant; survives rule changes.
  • Maps directly to Temporal.ZonedDateTime and to calendar formats like iCalendar.

Cons

  • Two fields instead of one.
  • Every reader must use a real zone database (the browser's Intl has one) to get the moment.

Wall time plus a fixed offset

Store 2026-11-01T01:30-04:00: an ISO string with its offset.

Pros

  • One string, unambiguous for that moment, standard ISO 8601.

Cons

  • An offset is not a zone. It can't tell you the offset of the next weekly meeting after the clocks change.
  • If rules change, the offset is silently wrong.

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.

ts
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"
Detecting a repeated time: if the earlier and later readings differ, the wall time is ambiguous.

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?

One component with a mode prop

DatePicker mode="single" | "range" | "multiple", and the value's type changes with the mode.

Pros

  • One import, one doc page.

Cons

  • The value type depends on another prop, which TypeScript can only express with awkward overloads.
  • Range-only props like minNights sit on single pickers where they mean nothing.

Separate components on a shared core

DatePicker, DateRangePicker, and a standalone Calendar and RangeCalendar.

All built from the same hooks: a field state, a calendar state, and the grid model.

Pros

  • Each has a clear value type and only the props that apply.
  • Teams can use the bare calendar inline (for example, on a booking page) without the field or popup.

Cons

  • More components to document and keep in sync.
  • Shared behavior has to live in hooks, not copied markup.

Headless hooks only

Ship useDatePicker and useCalendarGrid; each team renders its own markup.

Pros

  • Total freedom over markup and styling.

Cons

  • Every team re-creates ARIA, focus, and keyboard handling, and some get it wrong.
  • No consistent look across the product.

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.

ts
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
};
The props. Dates are ISO strings everywhere in the API; ranges are always complete when emitted.

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.

Three ways to type a date, side by side. Free text: one field with 14/03/2026 and a hint DD/MM/YYYY; paste works, but the order must be read. Segmented: one box with three parts, 14, 03, and 2026, changed with Up and Down; no ambiguity, but paste needs custom code. Three inputs: separate Day, Month, and Year fields; clear for birthdays, but they take more space.free text14/03/2026DD/MM/YYYYpaste worksorder must be readsegmented14032026arrows per partno ambiguitypaste is customthree inputs1432026Day, Month, Yearclear for birthdaystakes more space
All three produce the same ISO value. The free text field is the default; the three-input variant is for dates people already know, like a date of birth.

Decision What should the typing part look like?

One free text input with a parser

A normal input type="text". A hint below it shows the expected order, like "DD/MM/YYYY".

On blur, parse in the locale's order, accept any separator, and reformat the text.

Pros

  • Paste works. Screen readers and mobile keyboards treat it as a normal text field.
  • Small to build and test.

Cons

  • The user must know or read the order; mistakes are only caught on blur.
  • Ambiguous input like 3/4 must be rejected or guessed.

Segmented field (day, month, year as separate focus stops in one box)

Each part is a spinbutton; Up and Down change it, typing fills it and moves to the next part.

The parts are ordered and separated by the locale, so the order can't be misread.

Pros

  • No ambiguity: each part is labeled, invalid values are impossible to type.
  • Works well with a keyboard; React Aria and MUI ship this style.

Cons

  • Paste of a whole date needs custom handling.
  • Screen reader support for spin buttons varies, and some users find a tab stop per part confusing.
  • Much more code to get right in every locale.

Three separate labeled inputs

Three short text inputs labeled Day, Month, Year, in the locale's order, inside a fieldset with a legend.

Pros

  • Very clear for known dates like birthdays. Each field is a plain input.
  • The pattern the GOV.UK Design System recommends for dates people already know.

Cons

  • Not a single field, so it doesn't fit a compact filter bar.
  • Paste of a whole date needs custom handling too.

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.

ts
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
}
The order comes from the locale; any separator works; pasted ISO dates are accepted; impossible dates are rejected.

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.

February 2026 drawn three ways. With Sunday as the first day, its 28 days fill exactly 4 rows. With Monday first, February 1 sits alone at the end of row one and the month needs 5 rows. A fixed grid always shows 6 rows, with days of nearby months left empty, so its height never changes.Sunday first: 4 rows128height changesMonday first: 5 rows128height changesalways 6 rows128stable height
The same month needs 4 or 5 rows depending on the first day of the week, and other months need 6. A fixed 6-row grid keeps the popup the same height on every page.

Decision How many rows should every month show?

Only the rows the month needs (4 to 6)

Start at the first day's column, stop after the last day; pad the last row only.

Pros

  • No wasted space; February looks compact.

Cons

  • Height changes from month to month, so the layout jumps while paging.
  • A popup flipped above the field detaches from it when the month gets shorter.

Always 6 rows (42 cells), showing days from nearby months

Start at the first day of the week on or before the 1st, and generate 42 days.

Days outside the month are dimmed (or left blank) and still have real dates.

Pros

  • Stable height: no layout shift, and the popup stays put.
  • Simple code, and every month has the same number of cells for focus math and rendering.

Cons

  • Some months show a whole extra row of next month's days.
  • You must decide whether outside days can be focused and clicked.

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.

ts
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,
    };
  });
}
The grid is a pure function of month, first day of the week, rules, and today, so it's easy to test and to memoize.

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, because narrow gives "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, not String(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. Intl doesn'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. minimalDays from 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 with calendar: "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.

The calendar keyboard model, following the APG date picker dialog example.
KeyWhat it doesEdge case to handle
Right / Left arrowNext / previous dayCrossing into another month pages the grid. In right-to-left layouts the two are swapped.
Down / Up arrowSame weekday next / previous weekMoves 7 days, possibly into the next month.
Home / EndFirst / last day of the current week"First" depends on the locale's first day of the week.
Page Down / Page UpSame day next / previous monthJanuary 31 plus one month is February 28 (or 29): clamp to the month's length.
Shift + Page Down / UpSame day next / previous yearFebruary 29, 2028 plus one year is February 28, 2029.
Enter or SpaceSelect the focused dayIn a range, the first press sets the start, the second the end.
EscapeClose without changing the valueFocus returns to the button or field that opened it.
Tab / Shift + TabMove between the month buttons, the grid, and any footer buttonsThe grid is one tab stop.
Week starts (locale)
March 2026
MoTuWeThFrSaSu
Calendar state. Weeks start on Monday. Focus and tabindex 0 are on Saturday, March 14, 2026. Home goes to Monday, March 9, 2026 and End to Sunday, March 15, 2026. Selected: nothing. Last key: none yet.focus: Sat Mar 14tabindex=0: Sat Mar 14selected: noneweek starts: MondayHome: Mon Mar 9End: Sun Mar 15last key: none yetfocusable and selectablefocus stops at Mar 5 and Apr 20
Click a day, then use the arrow keys, Home, End, Page Up, and Page Down. Or press the key buttons.

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?

Skip them

Arrow keys jump over unavailable dates to the next available one.

Pros

  • Every focused day can be selected.

Cons

  • Arrows stop being predictable: Down might move 14 days, Right might jump a week of sold-out nights.
  • Screen reader users never learn that the skipped days exist or why they can't be picked.

Focus them, but they can't be selected

Unavailable days are focusable buttons with aria-disabled="true", announced as unavailable.

Enter on them does nothing, or shows why ("Sold out").

Pros

  • Arrows always move exactly one day or one week, the same as in every other calendar.
  • Users hear which days are blocked, which is part of the information.

Cons

  • A user can land on many blocked days in a row.
  • Needs clear styling so sighted keyboard users see the day is blocked.

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" and aria-labelledby pointing at it, so entering the grid announces "March 2026, grid".
  • Each day's accessible name is the full date, built with Intl.DateTimeFormat and dateStyle: "full": "Saturday, March 14, 2026", not "14". The visible text stays "14"; the full name goes in aria-label.
  • State goes in attributes, not words in the label where an attribute exists: aria-selected="true" on the selected day's gridcell (buttons don't support it, so each td cell 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.

A modal dialog

dialog.showModal(): the rest of the page becomes inert until the dialog closes.

This is what the APG date picker example uses.

Pros

  • Focus is trapped for free, and screen readers can't wander into the page behind.
  • Clear semantics: a dialog with a label.

Cons

  • Heavy for a small popup next to a field: a click outside doesn't close it unless you add that, and the page behind is blocked.
  • On desktop it feels like a modal window for a small choice.

A non-modal dialog in a popover

An element with role="dialog" and the popover attribute, opened with showPopover().

The popover sits in the top layer; Escape and a click outside close it (light dismiss).

Pros

  • Feels like a dropdown: light, closes on outside click, nothing behind is blocked.
  • The top layer ends clipping by overflow: hidden parents and z-index fights.

Cons

  • Focus is not trapped, so we define what Tab does at the edges.
  • Screen reader users can still move to page content while it's open.

Always inline, no popup

The calendar sits in the page, always visible, like a booking widget on a listing page.

Pros

  • No focus or placement problems at all.
  • Good when picking dates is the main job of the page.

Cons

  • Takes a lot of space; useless in a dense form or a filter bar.

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.

Sequence diagram of opening and closing the calendar, between the user, the calendar button, the picker state, the popover, and the grid. The user clicks the button or presses Enter. The button calls open on the picker state, which shows the popover. The popover moves focus to March 14 in the grid. The user presses Escape in the grid. The grid tells the picker state to close as a cancel, keeping the value. The picker state moves focus back to the calendar button.UserButtonPicker statePopoverGridclick or Enteropen()showPopover()focus Mar 14Escapeclose(cancel)focus() back
Focus lands on the day the user cares about, not the first button in the popover. On Escape the value doesn't change and focus returns to the button, unless the user already clicked somewhere else.

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.

Two viewports. On the left, the date field is near the top, so the calendar popover opens below it, aligned to its start edge. On the right, the field is near the bottom of the viewport, there is no room below, so the popover flips and opens above the field, still touching it.room belowdd/mm/yyyycalendarbelow: fitsnear the bottomcalendardd/mm/yyyyflipped aboveanchor positioning with flip-block, or a script fallback
Placement is below and start-aligned by default, flipping when there's no room. Because the grid always has 6 rows, a flipped popover keeps its height and stays attached while the user pages months.
css
.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; }
}
CSS anchor positioning places and flips the popover; a script positioner is the fallback.

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 range selection state machine with three states: Idle, holding a range or nothing; Start picked, holding the start and a preview end; and Check rules, testing the minimum stay and blocked nights. Picking a day in Idle goes to Start picked. In Start picked, hovering or moving focus updates the preview, and picking an earlier day restarts from it. Escape goes back to Idle with the previous range. Picking an end goes to Check rules. A valid range goes back to Idle and calls onChange with the range; an invalid one goes back to Start picked and says why.Idlerange or nullStart pickedstart + previewCheck rulesmin stay, blockedpick a dayhover or focus: previewearlier day: restartpick endEscapeinvalid: whyvalid: onChange(range)
The half-finished range lives only in Start picked, inside the component. The parent hears about a range once, on the valid path back to Idle.

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

Two weeks of March 2026, Monday first, from the 9th to the 22nd. The start, Tuesday the 10th, is picked. The pointer or focus is on Saturday the 14th, so the 11th to the 13th are tinted as a preview. The night of the 16th is booked, so the 16th can still be the check-out day, but the 17th to the 22nd are disabled as end dates.MoTuWeThFrSaSu910111213141516171819202122start: Tue Mar 10preview end: Sat Mar 14night of the 16th booked17 to 22 can't be the end
The preview follows the mouse and the keyboard focus alike. The first booked night caps how far the range can reach, and every later day says why it can't be the end.

Decision The user picked March 10 as the start, then clicks March 6. What should happen?

Swap them

Treat March 6 as the start and March 10 as the end, and commit.

Pros

  • One click fewer when the user meant that range.
  • Fine for report filters, where any two dates make a valid range.

Cons

  • Often not what the user meant: they changed their mind about the start.
  • Skips all the rules checked from the new start, like a minimum stay.

Restart from the new day

March 6 becomes the new start; the machine stays in start picked and waits for an end.

Pros

  • Matches the most common intent: "actually, I want to arrive earlier".
  • Every rule is checked from the real start.

Cons

  • A user who really wanted March 6 to 10 needs one more click.

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". With maxNights={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 allowsUnavailableInside turns 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 isDateUnavailable and 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.
ts
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
}
Each candidate end gets a reason or null. The reason becomes the cell's description, so blocked ends explain themselves.

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.

PartResponsibilityWhy the boundary is here
dateAdapterParse 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.
localeInfoCached 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.
monthGridPure function: month, first day, rules, today to 42 cells.No state, so it's trivial to test and memoize.
useDatePickerStateValue (controlled or not), open state, focused date, visible month.One owner for state, shared by the field and the calendar.
useRangeMachineIdle, start picked, check rules; preview end; rule checks.Range rules in one tested place, not spread over click handlers.
DateFieldText input, hint, parser, validation message, hidden form input.Usable alone, for forms that never need a calendar.
CalendarPopoverPopover or sheet shell, placement, focus in and focus back.Same shell as the select; layering bugs get fixed once.
Calendar / RangeCalendarHeader, month buttons, one or two CalendarGrids, live region.Usable inline on a booking page without the field or popup.
DayCellOne button: label, state attributes, styling hooks.Memoized, so it re-renders only when its own state changes.
Architecture of the date range picker on a booking page. On the left, the booking page has a BookingForm holding the value in the URL, an availability hook that fetches per month, and a server cache. In the middle, the DateRangePicker has a DateField and a Popover on top, the useDatePickerState hook holding value, open state, focus, and month, the useRangeMachine hook, and at the bottom 84 memoized DayCells inside the CalendarGrid. On the right, a pure core: dateAdapter for UTC day math, monthGrid building 42 cells, and localeInfo caching Intl formatters. The form passes the value into the picker state; the picker reports visible months to the availability hook, which passes a lookup down to the day cells. The pure core feeds the picker state, the grid, and the labels.Booking pageBookingFormvalue in URLavailabilityper monthServer cacheGETDateRangePickerDateFieldPopoveruseDatePickerStatevalue, open, focus, monthuseRangeMachineidle, start pickedDayCellsCalendarGridvaluemonthslookupPure coredateAdapterUTC mathmonthGrid42 cellslocaleInfoIntl cache
The page owns server data and the committed value; the picker owns focus, the open state, and the half-finished range; the pure core has no React and no DOM, so it's tested with plain tables.

Decision Who fetches sold-out days from the server?

The picker

The picker takes loadUnavailable(month) returning a promise, and calls it when the visible month changes.

Pros

  • Teams write less code for the common case.

Cons

  • The design system now owns caching, retries, and errors for every product's API.
  • Hard to share the same data with a price box or a summary elsewhere on the page.

The page, through two plain props

The picker emits onVisibleMonthChange("2026-03"); the page fetches with its own data layer and passes isDateUnavailable and isLoading.

Pros

  • The picker stays a pure UI component, testable without a network.
  • The page's server cache (for example TanStack Query) dedupes and shares availability with the rest of the page.

Cons

  • Each team writes a small hook; we ship an example.

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.

Data flow. Reading, top to bottom on the left: the Availability API returns JSON nights per month to the server cache, which keeps them fresh for one minute and shares them; the booking page turns them into a Set of sold-out days and passes isDateUnavailable to the picker, which looks up each of the 84 day cells. Writing, bottom to top on the right: the user picks the 11th and the 14th, the range machine checks the rules and passes a valid range to onChange, the page updates the URL and hidden form inputs with ISO date strings, and the form posts them to the Bookings API, which checks every rule again.Availability APIGET per monthJSON nightsServer cachefresh for 1 mindedupe and shareBooking pageSet of sold-out daysisDateUnavailablePickerrules as propslookup per cell84 DayCellsaria-disabled + whyBookings APIchecks every rule againURL + hidden inputsISO date stringsPOST ISO datesonChange(range)complete ranges onlyupdateuseRangeMachinechecks min stay, capsvalid rangeUser picks 11, 14click or Enterend picked
Availability flows down as a lookup; the value flows up as two ISO strings. The server never trusts the client's view of availability, which can be a minute old.

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.

Sequence diagram of picking a range when availability fails, between the user, the grid, the range machine, the page, and the API. The grid reports the visible months to the page. The page requests availability from the API, which fails with a 503. The page passes an error with a Retry button to the grid, and days stay selectable. The user clicks the 11th and then the 14th. The grid sends both picks to the range machine, which calls onChange with the range on the page. The page posts the booking to the API, which replies 201, or 409 if a night was taken.UserGridRange machinePageAPImonths visibleGET nights503, failederror + Retryclick 11, 14start, endonChange()POST booking201 or 409
When the hint fails, the user can still pick and book, because the server is the real check. A 409 from the booking call is the failure path that matters most: show which night was taken and reopen the calendar on it.

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

ts
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 };
Picker state. The range part is a tagged union, so "start picked without a start" can't be written.

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?

The native input type="date"

Render the native input on touch devices, with the same min, max, name, and ISO value.

Pros

  • The operating system's picker: familiar, usually works well with the phone's screen reader, localized by the OS, zero JavaScript.
  • Its value is always YYYY-MM-DD, so it plugs into our value model directly. (Read value, not valueAsDate: that one is a Date at UTC midnight, the same trap as before.)

Cons

  • No ranges, no individual blocked days, no reasons, no prices in cells.
  • Looks and behaves differently on iOS and Android, and its display format follows the browser or device settings, not the page's language.
  • Not perfectly accessible everywhere: Hassell Inclusion's tests found gaps, such as invalid dates not always being announced by screen readers.

Our calendar in a full-screen sheet

Below a width breakpoint, the popover becomes a modal sheet: months scroll vertically, large touch targets, a sticky footer with "Clear" and "Done".

Pros

  • Same rules and look as desktop: blocked nights, minimum stays, ranges.
  • Room for two or more months and a summary like "4 nights".

Cons

  • More code, and we own its accessibility on mobile screen readers.
  • A modal on a phone must handle the on-screen keyboard and the back gesture.

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

Rough sizes that change with versions: measure with your own bundler before deciding.
OptionRough gzipped costNotes
Our own adapter on Date.UTC and IntlAbout 1 to 2 KBOnly the dozen operations we need. Formatting and locale data come from the browser.
date-fnsA few KB for what a picker importsTree-shakable functions; cost grows with each function and locale imported.
Day.jsA few KB core, plus pluginsSmall, but works on Date objects, so the moment-versus-day trap is still yours to avoid.
LuxonRoughly 20 KBZone-aware and built on Intl; more than a date-only picker needs.
A Temporal polyfillRoughly 20 KB to 50 KB, by polyfillThe 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.setSystemTime in Vitest, jest.setSystemTime in Jest) and pick risky days on purpose: January 31, February 29, 2028, and December 31.
  • Run in several time zones. Set the TZ environment variable for Node test runs, for example TZ=America/Los_Angeles, TZ=Asia/Tokyo, and TZ=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 timezoneId and locale options run the real component in, say, ar-EG in Cairo and en-US in Los Angeles, catching first-day-of-week and right-to-left bugs.
  • Unit tests on the pure parts. The adapter, monthGrid, the parser, and canEndAt are 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.

MinutesWhat to doWhat to say out loud
0 to 5Ask 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 10Draw the wireframe: field, calendar button, popover with header and 6-row grid."Six rows always, so the popup's height never jumps."
10 to 18Write 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 28Keyboard and screen reader model; popover focus in and back."Roving tabindex, APG keys, full date names, month changes announced."
28 to 38Range 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 45Locales, 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?"

Think first

"Why not just send toISOString() to the server?"

Think first

"Why not use the native date input everywhere?"

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-DD string, never as a Date at 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; onChange emits only complete ranges; isDateUnavailable is 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 for getWeekInfo.
  • 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next