Date Picker
A calendar you can also type into. Ranges, presets, disabled dates — and the time-zone question you must answer before you build it.
Also called Calendar, Date Range Picker, Datetime Picker — in this system all of them are Date Picker.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Ranges with presets
Most range choices are one of five spans. Presets answer them in one click and leave the calendar for the case that is genuinely custom.
Typing must always work
A read-only field with a calendar attached is the fastest way to make a date-of-birth form hostile. Parse loosely, reformat on blur, and keep the grid as the alternative.
Unavailable dates
Disabled days must be visibly unavailable rather than silently rejected on submit — and the reason belongs near the field, not in an error afterwards.
Today is a ring, selection is a fill
Two different facts need two different channels. If both use a fill, the user cannot tell which day they picked.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A month header with paging controls, weekday column headers, and a seven-column grid of day cells. Today is ringed; the selection is filled.
- Panel width264px
Seven 32px cells plus gaps and padding. Fixed, so paging between a 28-day and a 31-day month never resizes the panel.
- HeaderMonth + year, aria-live
The live region is what lets a keyboard user hear the month change as they page. Without it, paging is silent.
- Day cell32 × 32px (44px on touch)
Square, so a 1 and a 31 are the same target. Tabular figures stop the column widths shifting between months.
- Weekday headers10px, muted, two letters
Two letters is the shortest form that stays unambiguous in English. The first day of the week is a locale setting, not a constant.
- TodayInset ring
A ring, not a fill. Today and the selection are different facts, and reusing the fill for both makes the choice invisible.
- Range fillSubtle tint, square corners
Square between the ends and rounded at them, so the span reads as one continuous bar rather than a row of chips.
- Row heightSix rows always
Reserve six week-rows even when the month needs five. A panel that changes height as you page moves the button you are aiming at.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Calendar panel |
| --ds-accent | — | Selected day fill |
| --ds-fg-on-accent | — | Selected day text |
| --ds-accent-subtle | — | Days inside a range |
| --ds-accent-border | — | The ring on today |
| --ds-layer-hover | — | Day hover |
| --ds-fg-muted | — | Weekday headers and paging chevrons |
| --ds-fg-disabled | — | Unavailable days |
Spacing
| Token | Value | Used for |
|---|---|---|
| cell gap | Between day cells |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Day cell corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | Day numbers, so columns never shift |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Min width | Touch target | When to use |
|---|---|---|---|---|
| Compact | 28px cells | 240px | — | Inside a dense filter bar or a narrow popover. |
| Default | 32px cells | 264px | — | The default. Seven cells plus gaps and padding. |
| Touch | 44px cells | 340px | 44px | Coarse pointers. Below 44px, adjacent days are routinely mis-tapped. |
| Range panel | — | 540px | — | Two months side by side, so a span crossing a month boundary is visible whole. |
| Presets | — | 9rem | — | A column beside the calendar. Five spans cover most range choices. |
aria-label="14 July 2026"Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The selected day carries a fill and inverted text, so it survives greyscale.
- Today’s ring must reach 3:1 — it is a meaningful boundary, not decoration.
- Disabled days may use the disabled tone, but their unavailability must also be exposed through the disabled attribute.
- The range tint must be distinguishable from the hover wash, or a user cannot tell what is selected while the pointer is in the grid.
Keyboard
| ← / → | Moves one day. ↑ / ↓ moves one week. |
| Page Up / Page Down | Moves one month. With Shift, one year. |
| Home / End | Jumps to the first or last day of the week. |
| Enter / Space | Selects the focused day and closes the panel. |
| Esc | Closes without selecting and returns focus to the field. |
| Tab | Moves between the paging controls and the grid. The grid itself is one stop with roving focus inside. |
Screen readers
- The grid announces as "July 2026, grid" then "14 July 2026, gridcell".
- Announce the month on every page. This is the single most common omission and it makes the picker unusable without sight.
- For a range, announce the span once both ends are set: "17 to 24 July 2026, 8 days".
Focus & touch
- Opening the panel moves focus to the selected day, or to today if nothing is selected. Roving tabindex means only one cell is tabbable, so Tab leaves the grid rather than walking thirty-one cells. Closing returns focus to the field with the value filled.
- Day cells go to 44px, which makes the panel about 340px wide — full-screen on most phones, and that is the right answer. Prefer native input[type=date] on mobile where the design allows: the platform picker is familiar, accessible and free. A two-month range panel does not fit a phone; stack the months and scroll.
| Attribute | Applied to | Notes |
|---|---|---|
| role="grid" | The month | With role="row" and role="gridcell". This is what makes arrow-key navigation announced correctly rather than improvised. |
| aria-label | Each day cell | The full date: "14 July 2026". A bare number tells a screen-reader user nothing. |
| aria-selected | The chosen day | Plus both ends of a range. The days between are conveyed by the label, not by selection. |
| aria-current="date" | Today | Distinct from selection. Both facts must be separately announced. |
| aria-live="polite" | The month header | Announces the month as the user pages. Without it, paging is completely silent. |
| aria-disabled | Unavailable days | With the reason available nearby — a silently unclickable day looks like a bug. |
Example usage
1import { DatePicker } from '@/ui/Input'23<Field label="Deployment date" description="Times are shown in Europe/London.">4 <DatePicker5 value={date}6 onChange={setDate}7 min={startOfToday}8 typeable // never a gate9 format="dd/MM/yyyy"10 />11</Field>1213// Parse loosely, reformat on blur. Users type dates six different ways and14// all of them are the same date.15const PATTERNS = ['dd/MM/yyyy', 'yyyy-MM-dd', 'd MMM yyyy', 'MM/dd/yyyy']16function parseLoose(input: string, locale: string) {17 for (const p of PATTERNS) {18 const d = parse(input, p, new Date(), { locale })19 if (isValid(d)) return d20 }21 return null22}2324// The grid keyboard model IS the component. Without it this is mouse-only.25function onGridKeyDown(e: React.KeyboardEvent) {26 const move = {27 ArrowLeft: -1, ArrowRight: 1,28 ArrowUp: -7, ArrowDown: 7,29 }[e.key]30 if (move) { e.preventDefault(); return setFocused(addDays(focused, move)) }31 if (e.key === 'PageUp') { e.preventDefault(); setFocused(addMonths(focused, -1)) }32 if (e.key === 'PageDown') { e.preventDefault(); setFocused(addMonths(focused, 1)) }33}3435// Store the instant, display the local date. Storing local midnight is where36// off-by-one bugs come from.37const iso = zonedTimeToUtc(startOfDay(date), 'Europe/London').toISOString()Framework-free HTML
<div class="ds-datepicker">
<label for="date">Deployment date</label>
<input id="date" type="text" inputmode="numeric"
placeholder="dd/mm/yyyy" aria-describedby="date-hint" />
<button type="button" aria-label="Open calendar" aria-haspopup="dialog"
aria-expanded="false">…</button>
<p id="date-hint">Times are shown in Europe/London.</p>
</div>
<div role="dialog" aria-label="Choose a date">
<div class="ds-cal__head">
<button type="button" aria-label="Previous month">‹</button>
<!-- Without the live region, paging is completely silent. -->
<span aria-live="polite">July 2026</span>
<button type="button" aria-label="Next month">›</button>
</div>
<div role="grid" aria-label="July 2026">
<div role="row">
<span role="columnheader" aria-label="Monday">Mo</span>
…
</div>
<div role="row">
<button role="gridcell" aria-label="21 July 2026"
aria-current="date" tabindex="-1">21</button>
<button role="gridcell" aria-label="24 July 2026"
aria-selected="true" tabindex="0">24</button>
<button role="gridcell" aria-label="9 July 2026" disabled>9</button>
</div>
</div>
</div>CSS
.ds-cal {
inline-size: 264px; /* 7 × 32px + gaps + padding */
padding: 12px;
border: 1px solid var(--ds-border);
border-radius: var(--radius-lg);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4);
}
.ds-cal__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
/* Six rows always. A five-week month is 32px shorter, and paging would
otherwise move every control below the panel. */
grid-auto-rows: 32px;
min-block-size: calc(6 * 32px + 5 * 2px);
}
[role='gridcell'] {
display: grid;
place-items: center;
border-radius: var(--radius-md);
/* Square cells and tabular figures: a 1 and a 31 are the same target and
the columns never shift between months. */
font-variant-numeric: tabular-nums;
color: var(--ds-fg-secondary);
}
[role='gridcell']:hover:not(:disabled) { background: var(--ds-layer-hover); }
[role='gridcell'][aria-selected='true'] {
background: var(--ds-accent);
color: var(--ds-fg-on-accent);
}
/* Today is a RING. Selection is a FILL. Two facts, two channels. */
[role='gridcell'][aria-current='date'] {
box-shadow: inset 0 0 0 1px var(--ds-accent-border);
}
/* Square between the ends, rounded at them: the span reads as one bar. */
[role='gridcell'][data-in-range='true'] {
border-radius: 0;
background: var(--ds-accent-subtle);
}
@media (pointer: coarse) {
.ds-cal { inline-size: 340px; }
.ds-cal__grid { grid-auto-rows: 44px; }
}Component API
DatePicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | Date | null | — | Null is empty. Store the instant; display the local date. |
| onChange* | (d: Date | null) => void | — | Fires on selection and on a successful parse from the field. |
| min / max | Date | — | Days outside the range render disabled, not merely rejected on submit. |
| typeable | boolean | true | Turning this off is almost always wrong. The calendar is an aid, not a gate. |
| format | string | locale default | Display format. Parsing stays loose regardless of what this is set to. |
| weekStartsOn | 0 | 1 | locale default | Monday across most of Europe and Asia. Never hardcode Sunday. |
| disabledDates | (d: Date) => boolean | — | For availability. Pair it with a visible reason near the field. |
Professional tips
- Show two months for range selection. A span crossing a month boundary is otherwise chosen half-blind.
- Highlight the range as the pointer moves between the two ends. It is the only preview of what clicking will produce.
- For availability calendars, show why a day is unavailable on hover or focus — "fully booked" beats a grey square.
- Default to something sensible rather than empty. Today, the next business day, or the start of the current month removes an interaction for most users.
- On mobile, consider native input[type=date] outright. The platform picker is familiar, accessible and costs nothing to maintain.
Performance
- Import only the date functions you use. A full date library in the entry bundle is a few hundred kilobytes for a component most users never open.
- Memoise the month grid on year and month. Recomputing thirty-one cells on every render is visible when the panel is open during typing.
- Load the picker lazily. It is a large component behind a single button and rarely needed on first paint.
- Never call Date.now() during render. It makes output non-deterministic and breaks any snapshot or server render.
Common mistakes
- A read-only field, forcing calendar navigation for a date the user already knows.
- Using the calendar for a date of birth, which means paging back hundreds of months.
- A panel that changes height between five-week and six-week months.
- Today and the selection sharing the fill, so the choice is invisible.
- A hardcoded Sunday week start, shifting every date by a column for most of the world.
- Bare day numbers as accessible names, announcing "14" with no month or year.
- No live region on the month header, making paging silent.
- Storing local midnight, which produces off-by-one dates for users in other zones.
Real-world recommendations
- Date range pickers in analytics tools are almost always used through the presets. Build those first and treat the calendar as the escape hatch.
- Booking flows are where the calendar genuinely earns its space: availability is spatial, and seeing which days are free is the whole task.
- Users type dates in whatever format they grew up with. Loose parsing removes more support tickets than any amount of placeholder text.
- Time zones are the source of most date bugs that reach production. Decide whether you are storing a calendar date or an instant, write it down, and be consistent.