Date picker
.datefield is the trigger — an input with a
calendar icon and a clear button. .datepicker is the popover panel it
opens. The behaviour is a class in deck.js; the stylesheet is the skin,
and its interesting part is the eight day states that compose into a range.
When to use it
Use it when the format matters, when you need a range, or when the reader benefits
from seeing a month at once. <input type="date"> is otherwise the
better answer — it is free, it is accessible, and it gives a phone the platform's own
picker.
The trade is explicit: the native control renders differently in every browser and cannot be styled, and it has no range mode. If neither matters to you, use it.
Here is a working one. Click the field — or focus it and press Enter, Space or Down — and the calendar opens.
<div class="field" style="max-inline-size:22rem">
<label class="label" for="dx-date-live">Ship date</label>
<div class="datefield" data-deck-datepicker data-format="iso">
<input class="input" id="dx-date-live" name="ship_date">
</div>
</div>
<div class="field" style="max-inline-size:22rem">
<label class="label" for="dx-date-live">Ship date</label>
<div class="datefield" data-deck-datepicker data-format="iso">
<input class="input" id="dx-date-live" name="ship_date">
</div>
</div>
That really is all of it. DatePicker.build() inserts the calendar icon
and the .datefield-clear button if they are not already there, sets
readonly and cursor: pointer on the input, creates the
popover panel and appends it to the body. Write the icon yourself only if you want a
different one — the script skips anything already present.
The attributes, all optional:
| Attribute | Default | What it does |
|---|---|---|
data-mode | single | single or range |
data-format | mdy | mdy, dmy, ymd or iso |
data-min / data-max | unbounded | ISO dates bounding the selectable range |
data-presets | off | Shows the shortcut rail |
data-months | 1, or 2 in range mode | How many months to show at once |
data-locale | the document's | Month and day names, and the default week start |
data-week-start | from the locale | 0 for Sunday, 1 for Monday |
The trigger field
.datefield positions an icon inside the input's starting edge — the same
technique as .search, with
pointer-events: none so clicking the icon focuses the field. The input is
readonly and cursor: pointer, because the value comes from
the calendar rather than the keyboard.
.datefield-clear is hidden until the field has a value:
.datefield:not(.has-value) .datefield-clear { display: none }.
.has-value is set by deck.js and is the one state here an
author never writes.
<div class="field" style="max-inline-size:22rem">
<label class="label" for="dx-date">Ship date</label>
<div class="datefield has-value">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#calendar"></use></svg>
<input class="input" id="dx-date" value="2026-03-11" readonly>
<button class="datefield-clear" aria-label="Clear date">
<svg class="icon icon-sm" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
</div>
<div class="field" style="max-inline-size:22rem">
<label class="label" for="dx-date">Ship date</label>
<div class="datefield has-value">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#calendar"></use></svg>
<input class="input" id="dx-date" value="2026-03-11" readonly>
<button class="datefield-clear" aria-label="Clear date">
<svg class="icon icon-sm" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
</div>
Opening and closing
The panel is a native popover, so most of its behaviour is the platform's. What
deck.js adds is the ways in:
| Action | Result |
|---|---|
| Click the field | Opens |
| Enter, Space or Down on the field | Opens |
| Escape | Closes — the popover's own behaviour |
| Click outside | Closes — light dismiss, also the popover's |
| Click the field again | Nothing. It stays open. |
That last row is a wart rather than a bug. The handler calls
showPopover() rather than togglePopover(), so the field
opens the panel and never closes it. Escape and an outside click both work, so nothing
is unreachable — but a reader who clicks the field expecting it to close will be
surprised. Recorded in FINDINGS.md.
deck.js also sets popovertarget on the input, which does
nothing: the attribute is only honoured on buttons. The click handler is what actually
opens the panel. aria-haspopup="dialog", set alongside it, is
meaningful and is what tells a screen reader the field opens something.
Eight day states
.datepicker-day is a 38px square — 42px on a coarse pointer, so it clears
the touch target on a phone. Eight classes compose on it, and the way they compose is
what makes a range selection read correctly.
| State | What it means |
|---|---|
.is-today | A dot beneath the number, drawn with ::after |
.is-selected | The chosen day: brand fill, and the today dot inverts to stay visible |
.is-outside | A day from the previous or next month, faded |
.is-blocked | Outside data-min/data-max, or otherwise unavailable |
.is-in-range | Between the two ends of a range: a tinted band |
.is-range-start | The first day, rounded on the starting side only |
.is-range-end | The last day, rounded on the ending side only |
.is-preview | The range the pointer is currently hovering toward |
The band is the fiddly part. A range that spans a week boundary has to look continuous
within each row and rounded where it stops, so the source uses
:has() to round the first and last cell of every row:
.datepicker-grid:has(.is-in-range) .datepicker-day.is-in-range:first-child { … }
.datepicker-grid:has(.is-in-range) .datepicker-day.is-in-range:nth-child(7n) { … }
.is-range-start.is-range-end — a single-day range — gets the full radius
back, so selecting one day twice does not leave a half-rounded pill.
All eight are set by deck.js at runtime and are
internal. They are documented because you may want to restyle them,
not because you should write them.
Positioning
The panel is a native popover — light dismiss, Escape and the top layer from the
platform — and src/25-anchor.css anchors it to its field with the same
position-area and fallback chain as a
.menu. Near the bottom of a window it
opens upward, and position-try-order: most-block-size picks the direction
with the most room rather than the first that fits.
That matters more for a calendar than for a menu: a picker that opens downward with four rows visible and the rest cut off is unusable, whereas a menu merely scrolls.
Presets and the month jump
.datepicker-presets is a rail of shortcuts — "Last 7 days", "This month".
Below 40rem it is a horizontal scroller above the calendar; from
40rem up, .datepicker-layout:has(.datepicker-presets) turns
the whole panel into a row and the rail becomes a column beside it.
.datepicker-jump opens .datepicker-months, a grid of twelve
months, so a reader choosing a date two years out does not press the next-month arrow
twenty-four times.
A picker without a jump is the most common date-picker complaint there is. If you are accepting dates more than a few months away, turn it on.
Classes
Generated from src/10-datepicker.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.datepicker |
deck.components |
The calendar panel: a native popover, anchored to its field by 25-anchor.css with the same fallback chain as a menu. Where there is no room below it opens upward, and position-try-order: most-block-size picks the direction with the most room rather than the first that fits — which matters more for a calendar than for a menu. | 10-datepicker.css:43 |
.datepicker-day |
deck.components |
One day cell. 38px, raised to 42px on a coarse pointer so it approaches the touch target. Eight is- states compose on it, and the range band is built from is-in-range plus logical corner radii on the two ends. | 10-datepicker.css:163 |
.datepicker-dow |
deck.components |
Sets text-align, font-size, font-weight, color, and 1 more. | 10-datepicker.css:152 |
.datepicker-foot |
deck.components |
Sets display, align-items, gap, padding, and 2 more. | 10-datepicker.css:265 |
.datepicker-grid |
deck.components |
Sets display, grid-template-columns, gap. | 10-datepicker.css:147 |
.datepicker-head |
deck.components |
Sets display, align-items, gap, padding-block-end. | 10-datepicker.css:111 |
.datepicker-jump |
deck.components |
Opens the month and year grid. A picker without one makes a reader press the next-month arrow twenty-four times to reach a date two years out. | 10-datepicker.css:233 |
.datepicker-layout |
deck.components |
Sets display, flex-direction. | 10-datepicker.css:66 |
.datepicker-main |
deck.components |
Sets padding. | 10-datepicker.css:109 |
.datepicker-month |
deck.components |
Sets display, flex-direction. | 10-datepicker.css:145 |
.datepicker-months |
deck.components |
Sets display, gap. | 10-datepicker.css:144 |
.datepicker-nav |
deck.components |
Sets inline-size, block-size, display, place-items, and 3 more. | 10-datepicker.css:132 |
.datepicker-preset |
deck.components |
Sets padding, min-block-size, border-radius, font-size, and 6 more. | 10-datepicker.css:93 |
.datepicker-presets |
deck.components |
The shortcut rail — "Last 7 days", "This month". A horizontal scroller below 40rem and a column beside the calendar above it. | 10-datepicker.css:74 |
.datepicker-readout |
deck.components |
Sets flex, font-size, color, font-variant-numeric, and 1 more. | 10-datepicker.css:273 |
.datepicker-time |
deck.components |
Sets display, align-items, gap, padding, and 1 more. | 10-datepicker.css:256 |
.datepicker-title |
deck.components |
Sets flex, display, align-items, justify-content, and 8 more. | 10-datepicker.css:117 |
.is-blocked |
deck.components |
No declarations. | 10-datepicker.css:181 |
.is-in-range |
deck.components |
No declarations. | 10-datepicker.css:207 |
.is-outside |
deck.components |
No declarations. | 10-datepicker.css:180 |
.is-preview |
deck.components |
No declarations. | 10-datepicker.css:227 |
.is-range-end |
deck.components |
No declarations. | 10-datepicker.css:216 |
.is-range-start |
deck.components |
No declarations. | 10-datepicker.css:212 |
.is-selected |
deck.components |
No declarations. | 10-datepicker.css:199 |
.is-today |
deck.components |
No declarations. | 10-datepicker.css:189 |
.datefield |
deck.components |
The trigger for a date picker: an input with a calendar icon inside its starting edge and a clear button at the end. The input is readonly and cursor: pointer, because the value comes from the calendar — which also means a reader who knows the date cannot type it. | 10-datepicker.css:14 |
.datefield-clear |
deck.components |
No declarations. | 10-datepicker.css:23 |
.has-value |
deck.components |
No declarations. | 10-datepicker.css:35 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--brand |
light-dark(var(--brand-600), var(--brand-400)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--text-on-brand |
oklch(99% .01 var(--hue-brand)) |
|
--r-md |
12px |
|
--r-sm |
7px |
|
--r-full |
999px |
|
--shadow-4 |
0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-6 |
1.5rem |
|
--space-10 |
2.5rem |
|
--dur-2 |
190ms |
Accessibility
-
Consider
<input type="date">first. It is fully accessible, gives a phone the platform picker, and needs nothing from you. A custom picker is a large accessibility surface to take on, and it should be taken on for a reason — a range, a format, a preset rail. -
The input is
readonly, notdisabled, so it stays focusable and its value is still submitted. That is the right choice, but it also means a reader cannot type a date — which is faster than clicking for anyone who knows the date they want. Consider allowing typed input for single-date fields. -
Days are 38px, below the
44px target — but
@media (pointer: coarse)raises them to 42px, which is close. This is the only place in Deck where a target is adjusted by pointer type rather than left at one value. -
A calendar grid needs keyboard navigation. Arrow keys between days,
Page Up and Down between months, Home and End for the week. The behaviour is in
deck.js; if you build your own panel with these classes, the keys are your responsibility. -
State is conveyed by more than colour. Today is a dot, selected is
a filled square, blocked is faded and gets the
disabledattribute. A range is a band, which is colour alone — so the selected ends carry the fill as well. -
Announce the chosen value.
.datepicker-readoutshows the current selection in the panel; put it in anaria-liveregion so a range being built is announced as it changes. -
The clear button needs a label. It is icon-only —
aria-label="Clear date", as in the example above. -
.is-previewis hover-only and therefore invisible to keyboard and touch. It is a nicety for pointer users, not a state anyone else relies on.
Right to left
The field's icon and clear button use inset-inline-start and
inset-inline-end, and the range ends use
border-start-start-radius and friends — logical corners — so a range
rounds on the correct side in either direction.
The calendar grid itself is a CSS grid, so the days of the week fill from the right
under dir="rtl". The week still starts where the locale says it does,
which is the picker's job rather than the stylesheet's.
Reduced motion
The panel fades and rises six pixels over
190ms, with overlay and
display in the transition so it stays rendered while it closes. The
global reset collapses it to .01ms under
prefers-reduced-motion: reduce. Nothing inside the calendar animates.
Printing
src/99-print.css lists .datepicker among the chrome that
never prints. The panel is a control for choosing a value; what prints is the value in
the field, which is an ordinary input and gets the standard
print treatment.
Overriding it
@layer app.components {
/* Larger days everywhere, not only on touch */
.datepicker-day { inline-size: 44px; block-size: 44px; }
/* A weekend tint */
.datepicker-day:nth-child(7n), .datepicker-day:nth-child(7n-6) {
color: var(--text-muted);
}
/* Keep the preset rail beside the calendar at every width */
.datepicker-layout:has(.datepicker-presets) { flex-direction: row; }
}
The :nth-child(7n) selectors assume a Sunday-first week. Which day starts
the week is a locale question, so a weekend tint that is correct everywhere needs the
picker to mark the cells rather than the stylesheet to count them.
When not to use it
-
Not when
<input type="date">would do. A single date with no format requirement is a solved problem, and the native control is better at it than any custom panel. -
Not for a date the reader knows. A birth date is faster typed than
navigated. If your picker is
readonly, a reader entering 1974 has to press the year jump and scroll. -
Not for a time.
.datepicker-timeexists for a time row inside the panel, but a standalone time is<input type="time">. - Not for a month or a quarter. Use a select — a calendar grid for twelve options is a lot of panel for a short list.
-
Not without
data-minanddata-maxwhen the valid range is known..is-blockedexists so the picker can say no before the server does.