Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Times are shown in Europe/London.

July 2026
MoTuWeThFrSaSu

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.

July 2026
MoTuWeThFrSaSu
17 – 24 July 2026 · 8 days

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.

Typeable
Accepts 21/07/2026, 2026-07-21, 21 Jul 2026
Calendar only
Born in 1974? Page back 624 months.

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.

July 2026
MoTuWeThFrSaSu
Deployments cannot be scheduled in the past.

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.

July 2026
MoTuWeThFrSaSu

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.

Idle field
Filled
Invalid
14
Day idle
14
Day selected
21
Today
17
In range
9
Disabled day
Preset

Anatomy

Every part, every measurement, and the reason it is that number.

July 2026
MoTuWeThFrSaSu

A month header with paging controls, weekday column headers, and a seven-column grid of day cells. Today is ringed; the selection is filled.

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

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

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

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

  5. TodayInset ring

    A ring, not a fill. Today and the selection are different facts, and reusing the fill for both makes the choice invisible.

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

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
cell gapBetween day cells

Radius

TokenValueUsed for
--radius-mdDay cell corners

Shadow

TokenValueUsed for
--shadow-e4—Panel elevation

Typography

TokenValueUsed for
tabular-nums—Day numbers, so columns never shift

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightMin widthTouch targetWhen to use
Compact28px cells240px—Inside a dense filter bar or a narrow popover.
Default32px cells264px—The default. Seven cells plus gaps and padding.
Touch44px cells340px44pxCoarse 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.

Do

Accepts several formats. Reformats on blur.
Let the field be typed intoThe calendar is an aid, not a gate. Anyone who already knows the date is fastest with a keyboard, and a read-only field punishes exactly them.
Offer presets for rangesMost range choices are one of about five spans. A preset answers them in a click and leaves the calendar for the genuinely custom case.
aria-label="14 July 2026"
Name the full date on every cell"14" read aloud is meaningless. "14 July 2026" is the value, and it costs one template string.
Times are shown in Europe/London.
State the time zone under the field"21 July" is a different instant in Auckland and Los Angeles. Saying which zone applies is the cheapest fix for a whole class of off-by-one bugs.

Don't

624 months back to 1974
Do not use a calendar for a date of birthPaging back six hundred months is not navigation. Three fields or one typed field with a format hint is faster by an order of magnitude.
Do not let the panel change height between monthsA five-week month is 32px shorter than a six-week one. Paging then moves every control below it, including the one the pointer is heading for.
2124
Do not mark today and the selection the same wayTwo different facts sharing one visual channel means the user cannot tell which day they picked. Today gets a ring; the selection gets the fill.
Su Mo Tu We Th Fr Sa
Do not assume the week starts on SundayIt starts on Monday across most of Europe and much of Asia. Getting it wrong shifts every date by a column and users misread the whole grid.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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 DownMoves one month. With Shift, one year.
Home / EndJumps to the first or last day of the week.
Enter / SpaceSelects the focused day and closes the panel.
EscCloses without selecting and returns focus to the field.
TabMoves 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.
AttributeApplied toNotes
role="grid"The monthWith role="row" and role="gridcell". This is what makes arrow-key navigation announced correctly rather than improvised.
aria-labelEach day cellThe full date: "14 July 2026". A bare number tells a screen-reader user nothing.
aria-selectedThe chosen dayPlus both ends of a range. The days between are conveyed by the label, not by selection.
aria-current="date"TodayDistinct from selection. Both facts must be separately announced.
aria-live="polite"The month headerAnnounces the month as the user pages. Without it, paging is completely silent.
aria-disabledUnavailable daysWith the reason available nearby — a silently unclickable day looks like a bug.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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 / maxDate—Days outside the range render disabled, not merely rejected on submit.
typeablebooleantrueTurning this off is almost always wrong. The calendar is an aid, not a gate.
formatstringlocale defaultDisplay format. Parsing stays loose regardless of what this is set to.
weekStartsOn0 | 1locale defaultMonday across most of Europe and Asia. Never hardcode Sunday.
disabledDates(d: Date) => boolean—For availability. Pair it with a visible reason near the field.

Notes

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.