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.

A live picker. That is the whole markup — the icon and the clear button are added for you.
<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:

Date picker attributes
AttributeDefaultWhat it does
data-modesinglesingle or range
data-formatmdymdy, dmy, ymd or iso
data-min / data-maxunboundedISO dates bounding the selectable range
data-presetsoffShows the shortcut rail
data-months1, or 2 in range modeHow many months to show at once
data-localethe document'sMonth and day names, and the default week start
data-week-startfrom the locale0 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.

Static markup, not wired — this one does not open. The clear button shows because .has-value is set by hand.
<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:

How the calendar opens and closes
ActionResult
Click the fieldOpens
Enter, Space or Down on the fieldOpens
EscapeCloses — the popover's own behaviour
Click outsideCloses — light dismiss, also the popover's
Click the field againNothing. 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.

Day states
StateWhat it means
.is-todayA dot beneath the number, drawn with ::after
.is-selectedThe chosen day: brand fill, and the today dot inverts to stay visible
.is-outsideA day from the previous or next month, faded
.is-blockedOutside data-min/data-max, or otherwise unavailable
.is-in-rangeBetween the two ends of a range: a tinted band
.is-range-startThe first day, rounded on the starting side only
.is-range-endThe last day, rounded on the ending side only
.is-previewThe 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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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, not disabled, 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 disabled attribute. A range is a band, which is colour alone — so the selected ends carry the fill as well.
  • Announce the chosen value. .datepicker-readout shows the current selection in the panel; put it in an aria-live region 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-preview is 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-time exists 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-min and data-max when the valid range is known. .is-blocked exists so the picker can say no before the server does.