Date pickers
Date pickers let people choose a day from a calendar rather than typing one. Reach for one when the day of the week, the position in the month or the distance from today matters, like a delivery slot, a holiday or an appointment. For a date the person already knows by heart, such as a birthday, the text field alone is usually faster. That is why every picker here keeps its input editable.
Three components cover the Material 3 variants: DockedDatePicker anchors a calendar
under a text field, DatePickerDialog opens the same calendar as a modal, and DateRangePicker selects a start and an end day.
Every picker uses the same value shape: an ISO YYYY-MM-DD string built from local
calendar fields, so it never slips a day across a timezone boundary. The docked and the modal
picker also keep a hidden input in sync, so passing name submits the value with the surrounding
form.
Usage
value is bindable. Typing in the field and picking a day stay in sync; inside the
calendar the selection is provisional until OK confirms it, and Cancel discards it.
The field stays yours while you type: a half-finished date is never rewritten, and value simply holds nothing until the text describes a real, selectable day. Leaving
the field tidies a loose entry, so 8/1/2025 settles as 08/01/2025. It
also marks the field invalid if what you typed is not a date it can take.
Value: 2025-08-17
<script lang="ts">
import { DockedDatePicker } from 'noph-ui'
let basic = $state<string | undefined>('2025-08-17')
</script>
<DockedDatePicker bind:value={basic} label="Date" />Anatomy
The docked container follows the Material 3 measurements exactly, so it lines up with the rest of a Material layout without nudging. Its height is not fixed: a docked picker sizes itself to the month, so it is 460dp for a month that spills into six week rows and 48dp shorter for every row it does not need. The height animates as you move between months.
| Part | Size |
|---|---|
| Container | 360dp wide, surface container high, 16dp corner |
| Header row | 24dp, on surface variant |
| Weekday labels | 24dp, with a 16dp gap to the grid |
| Date row | 48dp, holding a 40dp state layer and container |
| Selection menu row | 48dp, 24dp leading check, 16dp gutters |
| Action buttons | 36dp, 12dp from the bottom edge |
Reacting to a change
onchange fires whenever the committed value changes, whether it came from the
calendar or from typing. It receives the ISO string, or undefined when the field is cleared.
Last event: none yet
<DockedDatePicker
bind:value={watched}
label="Date"
onchange={(next) => console.log(next)}
/>Text field variants
The field is the library's TextField, so variant takes outlined (the default) or filled.
<DockedDatePicker bind:value label="Outlined" />
<DockedDatePicker bind:value label="Filled" variant="filled" />Localisation
Month names, weekday names and the numeric input order all come from Intl. Without a locale the runtime's own locale is used; pass one to pin it. The supporting text
under the field is generated from the same pattern, so a German picker asks for DD.MM.YYYY and parses 17.08.2025.
<DockedDatePicker bind:value label="Datum" locale="de-DE" />
<DockedDatePicker bind:value label="日付" locale="ja-JP" />First day of the week
The first column is derived from the locale through Intl.Locale#getWeekInfo. On an
engine that does not implement it the calendar falls back to Sunday, so set firstDayOfWeek explicitly (0 = Sunday … 6 = Saturday) when the week start has to be certain.
<DockedDatePicker bind:value label="Week starts Monday" firstDayOfWeek={1} />Bounding the selection
min and max are inclusive ISO days. They grey out the days outside the window,
stop the month and year steppers at the edge, and disable the months and years that fall entirely outside
it.
<DockedDatePicker
bind:value={bounded}
label="Within {thisYear}"
min="{thisYear}-01-01"
max="{thisYear}-12-31"
/>Disabling individual days
isDateEnabled runs for every rendered day; return false to disable it. Use
it for rules a range cannot express, such as weekends, public holidays or days already fully booked.
const weekdaysOnly = (date: Date) => date.getDay() !== 0 && date.getDay() !== 6
<DockedDatePicker
bind:value={booking}
label="Appointment"
min={isoToday}
isDateEnabled={weekdaysOnly}
/>Restricting the year menu
yearRange sets the years offered in the year menu, defaulting to [1900, 2100]. Narrowing it keeps a long scroll from getting in the way. For a date of
birth, pair it with a max of today.
<DockedDatePicker
bind:value={birthday}
label="Date of birth"
yearRange={[1920, 2026]}
max={isoToday}
/>Days of neighbouring months
The calendar shows only the days of the displayed month and leaves the surrounding cells empty,
the way the Material calendar draws it. The docked specification sheets do fill those cells, so adjacentMonthDays renders the leading and trailing days instead. Keyboard navigation crosses
the month boundary either way.
<DockedDatePicker
bind:value={adjacent}
label="With adjacent days"
adjacentMonthDays
/>Controlling the calendar
displayMonth is the month on screen. It follows the selection until the person navigates,
and binding it lets you park the calendar somewhere useful, such as the start of a booking season. You
can also read back where they browsed to while the calendar is open: closing it restores whatever you
set, so the picker reopens where you put it rather than three years away.
Showing: 2025-08-01
<DockedDatePicker
bind:value={steered}
bind:displayMonth={steeredMonth}
bind:open={steeredOpen}
label="Season"
/>
<Button
onclick={() => {
steeredMonth = '2026-12-01'
steeredOpen = true
}}
>
Show December
</Button>Opening it yourself
open is bindable, so the calendar can be opened from anywhere and read back when the person
dismisses it. Clicking outside still closes it, so drive it with a plain open button rather than a toggle.
Open: false
<DockedDatePicker bind:value bind:open={isOpen} label="Date" />
<Button onclick={() => (isOpen = true)}>Open the calendar</Button>Forms and validation
Passing a name submits the ISO value with the surrounding form through a hidden
input, so no JavaScript is needed to read it back. The constraints stay on the visible field
rather than that hidden one, so a blocked submit reports against a control the browser can focus
and point at, and the picker validates the way a native <input type="date"> does.
That means :user-invalid switches on at a submit attempt, which browsers do even for
a form marked novalidate, and never while a date is still being typed. The field is
additionally marked on blur, which the platform only does in Firefox, so the feedback is the same
everywhere. invalidDateMessage sets the text the browser reports.
issues replaces the supporting text with your own messages and turns the field red, so
it pairs with whatever validation library the form already uses.
Nothing submitted yet.
<form onsubmit={handleSubmit} novalidate>
<DockedDatePicker
bind:value={formValue}
label="Delivery date"
name="deliveryDate"
issues={formIssues}
min={isoToday}
required
/>
<Button type="submit" variant="filled">Submit</Button>
</form>Modal
DatePickerDialog is the same calendar in a modal, with the selection echoed in a headline
and a three-column year grid behind the month button. Prefer it on small screens, or when choosing the
date is the whole point of the step rather than one field among many.
Value: 2025-08-17
<Button onclick={() => (dialogOpen = true)}>Pick a date</Button>
<DatePickerDialog bind:open={dialogOpen} bind:value={dialogValue} />Keyboard entry and custom wording
modeToggle adds the header button that swaps the calendar for a text field, for
people who would rather type. title replaces the supporting line above the headline,
and headline overrides the formatted date itself. onconfirm fires on the
confirm button, and oncancel on every other way out: the cancel button, Escape, a click on the scrim, or setting open back to false yourself.
<DatePickerDialog
bind:open={entryOpen}
bind:value={entryValue}
title="Arrival date"
modeToggle
min={isoToday}
onconfirm={(next) => console.log(next)}
/>Range
DateRangePicker scrolls through months continuously and fills the days between the
two ends. The first tap sets the start, the second the end; tapping before the start restarts the
range. value is a { start, end } object of ISO days.
The month list is a window rather than the whole year range: it opens on a few months either side of the start day and grows as you scroll, and each month is only as tall as the week rows it needs. The seven grids share one tab stop, so the list is one stop in the tab order and the arrow keys carry focus from one month into the next.
The picker takes the presentation the window allows. Below 600dp it fills the screen, square
cornered and flat, and confirms from the top bar. From 600dp up it is an ordinary modal instead: a
dialog the width of the calendar, sitting in the scrim with rounded corners, the month list
scrolling inside it and Cancel and Save at the bottom. The switch is a media
query, so it costs nothing to render and the server sends what the browser shows. Narrow the window
to see it change.
Value: undefined → undefined
<script lang="ts">
import { DateRangePicker } from 'noph-ui'
import type { DateRange } from 'noph-ui/types'
let range = $state<DateRange>({})
let rangeOpen = $state(false)
</script>
<Button onclick={() => (rangeOpen = true)}>Pick a range</Button>
<DateRangePicker bind:open={rangeOpen} bind:value={range} title="Select stay" />Two fields
A booking form usually shows the range as two fields rather than a button, and the calendar is
what opens behind them. DateRangePicker is only the popup, so the pair of fields
stays yours: bind the same { start, end } to both, and the range is the single
place the two edges live.
The calendar is the only editor, so the fields are read-only and simply read the range back
through formatDate. That is the whole example: nothing mirrors the text and nothing has to be
kept in sync, because the two edges only ever change in one place. Clicking a field opens the
picker, and the calendar button does the same for the keyboard.
Value: undefined → undefined
<script lang="ts">
import { DateRangePicker, formatDate, IconButton, parseISODate, TextField } from 'noph-ui'
import { CalendarToday } from 'noph-ui/icons'
import type { DateRange } from 'noph-ui/types'
let open = $state(false)
let stay = $state<DateRange>({})
const asText = (iso?: string) => {
const date = parseISODate(iso)
return date ? formatDate(date) : ''
}
</script>
{#snippet calendarButton(edge: string)}
<IconButton
type="button"
aria-label="Open the calendar for the {edge} date"
aria-haspopup="dialog"
aria-expanded={open}
onclick={() => (open = true)}
>
<CalendarToday />
</IconButton>
{/snippet}
{#snippet startButton()}{@render calendarButton('start')}{/snippet}
{#snippet endButton()}{@render calendarButton('end')}{/snippet}
<TextField
label="Start"
readonly
value={asText(stay.start)}
onclick={() => (open = true)}
end={startButton}
/>
<TextField
label="End"
readonly
value={asText(stay.end)}
onclick={() => (open = true)}
end={endButton}
/>
<DateRangePicker bind:open bind:value={stay} title="Select stay" />Theming
Colours and shapes come from the theme, and every part exposes a custom property for the cases the theme cannot reach. Set them on the picker itself; they inherit into the calendar.
| Property | Default |
|---|---|
--np-docked-date-picker-container-color | --np-color-surface-container-high |
--np-docked-date-picker-container-shape | --np-shape-corner-large |
--np-docked-date-picker-container-width | 22.5rem (360dp) |
--np-date-picker-header-color | --np-color-on-surface-variant |
--np-date-picker-weekday-label-color | --np-color-on-surface |
--np-date-picker-date-container-shape | --np-shape-corner-full |
--np-date-picker-date-selected-container-color | --np-color-primary |
--np-date-picker-date-selected-label-color | --np-color-on-primary |
--np-date-picker-date-today-outline-color | --np-color-primary |
--np-date-picker-date-today-label-color | --np-color-primary |
--np-date-picker-menu-selected-container-color | --np-color-surface-variant |
--np-date-picker-year-selected-container-color | --np-color-primary |
--np-date-picker-year-selected-label-color | --np-color-on-primary |
--np-date-picker-range-active-indicator-color | --np-color-secondary-container |
--np-date-picker-range-month-subhead-color | --np-color-on-surface-variant |
--np-date-picker-dialog-container-color | --np-color-surface-container-high |
--np-date-picker-dialog-container-shape | --np-shape-corner-extra-large |
--np-date-picker-dialog-container-width | 22.5rem (360dp) |
--np-date-range-picker-container-color | --np-color-surface full screen, --np-color-surface-container-high as a modal |
--np-date-range-picker-container-shape | --np-shape-corner-none full screen, --np-shape-corner-extra-large as a modal |
--np-date-range-picker-content-width | 25.5rem (408dp) full screen, 22.5rem (360dp) as a modal |
--np-date-range-picker-months-max-height | 20rem (320dp), the modal's scrolling month list |
Example
<DockedDatePicker
bind:value
label="Date"
--np-date-picker-date-selected-container-color="var(--np-color-tertiary)"
--np-date-picker-date-selected-label-color="var(--np-color-on-tertiary)"
--np-date-picker-date-today-outline-color="var(--np-color-tertiary)"
--np-date-picker-date-today-label-color="var(--np-color-tertiary)"
--np-date-picker-date-container-shape="var(--np-shape-corner-small)"
/>Motion and gestures
Movement follows the Material 3 motion scheme, using the theme's own motion tokens: travel runs on the spatial tokens, fades on the effects tokens.
| What moves | How | Token |
|---|---|---|
| Month change | The grid slides in from the direction of travel and fades up. | --np-motion-expressive-default-spatial |
| Container resize | Height animates as the month's week-row count changes. | --np-motion-expressive-default-spatial |
| Month and year menus | Expand vertically and fade in from 60% opacity. | --np-motion-expressive-default-effects |
| Day selection | Container and label colours cross-fade. Days inside a range change instantly. | --np-motion-expressive-default-effects |
| Calendar / keyboard entry | The text field slides up from below; the calendar slides down from a 48dp parallax. | --np-motion-expressive-default-spatial |
Swipe horizontally across the calendar to move between months, and scroll the year picker
vertically to move between years. Every transition above is wrapped in prefers-reduced-motion: no-preference, so the picker resizes and swaps views
instantly for anyone who has asked for less motion.
Accessibility
The grid is a role="grid" table named after the month it shows, with the weekday
names as column headers and a single roving tab stop, so the calendar is one stop in the tab order
rather than forty-two. Each day is labelled with its full date, today carries aria-current="date", and the selected day is aria-selected on its cell
and names itself "…, selected" so the state is announced on the day that has focus. selectedDateLabel translates that suffix.
| Key | Moves |
|---|---|
| ← → | One day |
| ↑ ↓ | One week |
| Home End | Start or end of the week |
| Page Up Page Down | One month |
| Shift + Page Up/Page Down | One year |
| Enter Space | Select the focused day |
| Esc | Close the calendar |
Navigating past the edge of the month moves to the neighbouring one and keeps focus on the day it
lands on, in the range picker too. A key that would leave the min and max window stops on the bound instead. The label strings are all props, including cancelLabel, confirmLabel, openCalendarLabel, selectedDateLabel and the month and year navigation labels, so a localised app can translate
the whole control.
The modal picker opens with focus on the grid rather than on the dialog, so the arrow keys work
straight away, and it names itself with its title.
API
DockedDatePicker
Anything not listed here is forwarded to the picker's root element.
| Attribute | Type | Default | Description |
|---|---|---|---|
label | string | 'Date' | Label of the text field. |
variant | 'outlined' | 'filled' | 'outlined' | Text field variant. |
supportingText | string | generated | Replaces the generated MM/DD/YYYY hint. |
issues | { message: string }[] | undefined | Validation messages shown instead of the supporting text. |
locale | string | runtime locale | BCP 47 tag driving names and the input order. |
firstDayOfWeek | number | from locale | First column, 0 = Sunday … 6 = Saturday. |
min / max | string | undefined | Inclusive ISO bounds of the selectable window. |
yearRange | [number, number] | [1900, 2100] | Years offered in the year menu. |
isDateEnabled | (date: Date) => boolean | undefined | Return false to disable a day. |
adjacentMonthDays | boolean | false | Fill the leading and trailing cells with the neighbouring months. |
name / form | string | undefined | Wire the hidden native date input into a form. |
required | boolean | false | Marks the field required for native validation. |
disabled / readonly | boolean | false | Disables the field, or blocks opening the calendar. |
noAsterisk | boolean | false | Hides the asterisk on a required label. |
autocomplete | string | 'off' | Autocomplete hint for the text field. |
openCalendarLabel | string | 'Show date picker' | Accessible name of the calendar toggle. |
invalidDateMessage | string | 'Enter a valid date.' | Validation message reported when the typed text is not a date the picker can take. |
cancelLabel / confirmLabel | string | 'Cancel' / 'OK' | Action button text. |
previousMonthLabel / nextMonthLabel | string | 'Previous month' / 'Next month' | Accessible names of the month steppers. |
previousYearLabel / nextYearLabel | string | 'Previous year' / 'Next year' | Accessible names of the year steppers. |
selectMonthLabel / selectYearLabel | string | 'Select month' / 'Select year' | Accessible names of the menu buttons. |
selectedDateLabel | string | 'selected' | Appended to the accessible name of the selected day. |
onchange | (value?: string) => void | undefined | Fires when the committed value changes. |
Bindables
| Attribute | Type | Description |
|---|---|---|
value | string | null | undefined | Selected day as YYYY-MM-DD. |
displayMonth | string | Month on screen, as an ISO day. Follows value until navigated, and is restored to
what you set when the calendar closes. |
open | boolean | Whether the docked calendar is showing. |
element | HTMLDivElement | The picker's root element. |
DatePickerDialog
Takes the same locale, firstDayOfWeek, min, max, yearRange, isDateEnabled, adjacentMonthDays, label, supportingText, name, form, cancelLabel, confirmLabel, previousMonthLabel, nextMonthLabel, selectYearLabel, selectedDateLabel and onchange props as DockedDatePicker. It has no year steppers or month menu, so those labels do not
apply, and it validates by disabling confirm rather than through the field, so required and invalidDateMessage do not either. On top of the shared props:
| Attribute | Type | Default | Description |
|---|---|---|---|
title | string | 'Select date' | Supporting line above the headline, and the accessible name of the dialog. |
headline | string | formatted date | Overrides the headline text. |
modeToggle | boolean | false | Shows the calendar / keyboard-entry toggle. |
inputModeLabel / calendarModeLabel | string | 'Switch to text input mode' / 'Switch to calendar mode' | Accessible name of that toggle, in each of its two states. |
onconfirm | (value?: string) => void | undefined | Fires when the confirm button is pressed. |
oncancel | () => void | undefined | Fires on any dismissal that is not a confirm, including Escape and the scrim. |
Bindable: value, displayMonth, open and element. Calendar navigation is not kept: the dialog reopens on the month it was
given, or on the month of value.
DateRangePicker
Takes the same locale, firstDayOfWeek, min, max, yearRange, isDateEnabled, adjacentMonthDays and selectedDateLabel props as the other pickers. It has
no text field and no month or year navigation, so none of those labels apply, and it does not submit
with a form.
| Attribute | Type | Default | Description |
|---|---|---|---|
title | string | 'Select range' | Accessible name of the dialog. |
startLabel / endLabel | string | 'Start date' / 'End date' | Headline placeholders before each end is chosen. |
cancelLabel / confirmLabel | string | 'Cancel' / 'Save' | Accessible name of the close button, and the confirm button's text. |
onchange / onconfirm | (value: DateRange) => void | undefined | Both fire when the range is saved, matching the other pickers. |
oncancel | () => void | undefined | Fires on any dismissal that is not a save, including Escape and the scrim. |
Bindable: value (a DateRange), open and element.
Calendar and YearGrid
The two grids the pickers are built from are exported as well, for a layout none of the three
covers, such as a calendar sitting permanently on a page. They are lower level than the pickers:
they hold no value, take Date objects rather than ISO strings, and leave the month on screen,
the selection and the keyboard entry points to the caller.
| Component | Attributes |
|---|---|
Calendar | month and firstDayOfWeek are required. Then selected, rangeStart, rangeEnd, min, max, todayDate and focusedDate as Date objects, plus locale, isDateEnabled, adjacentMonthDays, selectedLabel, weekdays to hide
the column headers, dynamicRows to size the grid to the month rather than
reserve six rows, and a monthSubhead snippet. It reports through onselect, onfocusday and onmonthstep. Several
calendars shown together share one tab stop through tabStopDate and hand focus
across a month boundary through focusRoot. |
YearGrid | yearRange and value are required, with optional minDate, maxDate and onselect. |
Date helpers
The date maths behind the pickers is exported too, so an app can share the same timezone-safe handling.
| Function | Description |
|---|---|
toISODate(date) | Serialises a Date to YYYY-MM-DD from local fields. |
parseISODate(value) | Parses to a local-midnight Date, or undefined if invalid. |
parseDateInput(text, locale) | Parses typed text in the locale's field order. |
formatDate / formatDateMedium / formatDateLong | Numeric, Aug 17, 2025 and full-weekday forms. |
getDatePattern(locale) | The MM/DD/YYYY style hint for a locale. |
getWeekdayLabels / getMonthNames | Localised weekday and month names. |
getFirstDayOfWeek(locale) | Week start as 0 = Sunday … 6 = Saturday. |
getCalendarDays(month, firstDayOfWeek) | The six-week grid for a month. |
addDays / addMonths / startOfMonth | Date maths; addMonths clamps short months. |
isSameDay / isSameMonth / isWithin / compareDays | Day-precision comparisons. |