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.

PartSize
Container360dp wide, surface container high, 16dp corner
Header row24dp, on surface variant
Weekday labels24dp, with a 16dp gap to the grid
Date row48dp, holding a 40dp state layer and container
Selection menu row48dp, 24dp leading check, 16dp gutters
Action buttons36dp, 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>

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.

PropertyDefault
--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-width22.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-width22.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-width25.5rem (408dp) full screen, 22.5rem (360dp) as a modal
--np-date-range-picker-months-max-height20rem (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 movesHowToken
Month changeThe grid slides in from the direction of travel and fades up.--np-motion-expressive-default-spatial
Container resizeHeight animates as the month's week-row count changes.--np-motion-expressive-default-spatial
Month and year menusExpand vertically and fade in from 60% opacity.--np-motion-expressive-default-effects
Day selectionContainer and label colours cross-fade. Days inside a range change instantly.--np-motion-expressive-default-effects
Calendar / keyboard entryThe 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.

KeyMoves
One day
One week
Home EndStart or end of the week
Page Up Page DownOne month
Shift + Page Up/Page DownOne year
Enter SpaceSelect the focused day
EscClose 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.

AttributeTypeDefaultDescription
labelstring'Date'Label of the text field.
variant'outlined' | 'filled''outlined'Text field variant.
supportingTextstringgeneratedReplaces the generated MM/DD/YYYY hint.
issues{ message: string }[]undefinedValidation messages shown instead of the supporting text.
localestringruntime localeBCP 47 tag driving names and the input order.
firstDayOfWeeknumberfrom localeFirst column, 0 = Sunday … 6 = Saturday.
min / maxstringundefinedInclusive ISO bounds of the selectable window.
yearRange[number, number][1900, 2100]Years offered in the year menu.
isDateEnabled(date: Date) => booleanundefinedReturn false to disable a day.
adjacentMonthDaysbooleanfalseFill the leading and trailing cells with the neighbouring months.
name / formstringundefinedWire the hidden native date input into a form.
requiredbooleanfalseMarks the field required for native validation.
disabled / readonlybooleanfalseDisables the field, or blocks opening the calendar.
noAsteriskbooleanfalseHides the asterisk on a required label.
autocompletestring'off'Autocomplete hint for the text field.
openCalendarLabelstring'Show date picker'Accessible name of the calendar toggle.
invalidDateMessagestring'Enter a valid date.'Validation message reported when the typed text is not a date the picker can take.
cancelLabel / confirmLabelstring'Cancel' / 'OK'Action button text.
previousMonthLabel / nextMonthLabelstring'Previous month' / 'Next month'Accessible names of the month steppers.
previousYearLabel / nextYearLabelstring'Previous year' / 'Next year'Accessible names of the year steppers.
selectMonthLabel / selectYearLabelstring'Select month' / 'Select year'Accessible names of the menu buttons.
selectedDateLabelstring'selected'Appended to the accessible name of the selected day.
onchange(value?: string) => voidundefinedFires when the committed value changes.

Bindables

AttributeTypeDescription
valuestring | null | undefinedSelected day as YYYY-MM-DD.
displayMonthstringMonth on screen, as an ISO day. Follows value until navigated, and is restored to what you set when the calendar closes.
openbooleanWhether the docked calendar is showing.
elementHTMLDivElementThe 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:

AttributeTypeDefaultDescription
titlestring'Select date'Supporting line above the headline, and the accessible name of the dialog.
headlinestringformatted dateOverrides the headline text.
modeTogglebooleanfalseShows the calendar / keyboard-entry toggle.
inputModeLabel / calendarModeLabelstring'Switch to text input mode' / 'Switch to calendar mode'Accessible name of that toggle, in each of its two states.
onconfirm(value?: string) => voidundefinedFires when the confirm button is pressed.
oncancel() => voidundefinedFires 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.

AttributeTypeDefaultDescription
titlestring'Select range'Accessible name of the dialog.
startLabel / endLabelstring'Start date' / 'End date'Headline placeholders before each end is chosen.
cancelLabel / confirmLabelstring'Cancel' / 'Save'Accessible name of the close button, and the confirm button's text.
onchange / onconfirm(value: DateRange) => voidundefinedBoth fire when the range is saved, matching the other pickers.
oncancel() => voidundefinedFires 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.

ComponentAttributes
Calendarmonth 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.
YearGridyearRange 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.

FunctionDescription
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 / formatDateLongNumeric, Aug 17, 2025 and full-weekday forms.
getDatePattern(locale)The MM/DD/YYYY style hint for a locale.
getWeekdayLabels / getMonthNamesLocalised 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 / startOfMonthDate maths; addMonths clamps short months.
isSameDay / isSameMonth / isWithin / compareDaysDay-precision comparisons.