DatePicker
When to use
Section titled “When to use”- A date people may type or pick: a due date, a birthday, a booking
- A range with a start and an end, optionally with times: a trip, leave, a booking window
To pick from a calendar with no field, as part of the page, use Calendar.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
Type it, or pick it from the calendar.
import { useState } from 'react';import { DatePicker, Field, Text } from '@arun-dev/ui';
export default function DatePickerBasics() { // An ISO date, `YYYY-MM-DD`, or null — exactly what a native date input holds. const [due, setDue] = useState<string | null>('2026-10-16');
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}> <Field.Root> <Field.Label>Due date</Field.Label> <Field.Control render={<DatePicker value={due} onValueChange={setDue} />} /> <Field.Description>Type it, or pick it from the calendar.</Field.Description> </Field.Root> <Text size="sm" color="secondary" aria-live="polite"> value: {JSON.stringify(due)} </Text> </div> );}How it works
Section titled “How it works”The field is a native <input type="date">, so typing, the format of the user’s locale, the
phone’s own picker and the form are the browser’s. The button beside it opens a
Calendar in a popover; picking a day fills the field and closes it.
| Value | Shape | For example |
|---|---|---|
| A date | YYYY-MM-DD | '2026-10-16' |
With withTime | YYYY-MM-DDTHH:mm | '2026-10-16T09:30' |
| A range | { start, end }, each as above | { start: '2026-10-12', end: '2026-10-16' } |
| Nothing (or not yet) | null, or a null end in a range | { start: '2026-10-12', end: null } |
These are the strings the native inputs hold, in the user’s local time, with no time zone.
With a time
Section titled “With a time”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DatePicker, Field, Text } from '@arun-dev/ui';
export default function DatePickerTime() { // With withTime, `YYYY-MM-DDTHH:mm`, in the user's local time. Picking a day keeps the time. const [start, setStart] = useState<string | null>('2026-10-16T09:30');
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}> <Field.Root> <Field.Label>Meeting starts</Field.Label> <Field.Control render={<DatePicker withTime value={start} onValueChange={setStart} />} /> </Field.Root> <Text size="sm" color="secondary" aria-live="polite"> value: {JSON.stringify(start)} </Text> </div> );}withTime makes the field a datetime-local input, and adds a Time field and a Done
button under the calendar. The Time field is the browser’s own time input, so in Chrome its clock
button opens the same hour, minute and AM/PM columns as the native picker.
With a time, picking a day keeps the popup open, so the time can be set next; Done, Esc or a click outside closes it. A picked day keeps the time already set, or uses the time chosen in the popup, or midnight. Both fields stay in step: change either, and the value follows.
Far-off dates: a date of birth
Section titled “Far-off dates: a date of birth”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DatePicker, Field, Text } from '@arun-dev/ui';
// No birthdays in the future: max is today, so the year list ends at this year.const today = new Date().toISOString().slice(0, 10);
export default function DatePickerBirth() { const [born, setBorn] = useState<string | null>('1990-06-15T07:30');
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}> <Field.Root> <Field.Label>Date and time of birth</Field.Label> {/* Month and year selects jump decades in one move; the time is set under the calendar. */} <Field.Control render={ <DatePicker withTime captionLayout="dropdown" min="1900-01-01" max={today} value={born} onValueChange={setBorn} /> } /> </Field.Root> <Text size="sm" color="secondary" aria-live="polite"> value: {JSON.stringify(born)} </Text> </div> );}Paging a month at a time is fine for next week, not for 1990. captionLayout="dropdown" puts
month and year selects above the days: native <select>s, so typing “1990” or a phone’s
wheel jumps straight there, and the arrow keys and Page Up/Down still move the calendar, the
selects following. The years run from min’s to max’s, or else from 100 years back to 10
ahead; for a birthday, max is today. It works the same in a range, and on its own
Calendar.
A wedding anniversary is the same kind of date: in the past, often decades back, and with no time.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DatePicker, Field, Text } from '@arun-dev/ui';
const today = new Date().toISOString().slice(0, 10);
/** The next time the day comes round, on or after today, and the years it will mark. */function nextAnniversary(wedding: string) { const [y, m, d] = wedding.split('-'); const thisYear = Number(today.slice(0, 4)); const candidate = `${thisYear}-${m}-${d}`; const year = candidate >= today ? thisYear : thisYear + 1; return { date: `${year}-${m}-${d}`, years: year - Number(y) };}
export default function DatePickerAnniversary() { const [wedding, setWedding] = useState<string | null>('2001-02-14'); const next = wedding ? nextAnniversary(wedding) : null;
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}> <Field.Root> <Field.Label>Wedding date</Field.Label> {/* In the past, perhaps decades: the selects reach it, and max stops a future date. */} <Field.Control render={ <DatePicker captionLayout="dropdown" max={today} value={wedding} onValueChange={setWedding} /> } /> </Field.Root> <Text size="sm" color="secondary" aria-live="polite"> {next ? `Next anniversary: ${next.date}, ${next.years} ${next.years === 1 ? 'year' : 'years'}` : 'No date yet'} </Text> </div> );}ISO input
Section titled “ISO input”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DatePicker, Field, Text } from '@arun-dev/ui';
// From an API: a UTC instant, with Z. The picker shows it in the user's time zone.const FROM_SERVER = '2026-10-16T08:00:00Z';
export default function DatePickerIso() { const [value, setValue] = useState<string | null>(FROM_SERVER);
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}> <Field.Root> <Field.Label>Publish at</Field.Label> <Field.Control render={<DatePicker withTime value={value} onValueChange={setValue} />} /> </Field.Root> <Text size="sm" color="secondary" aria-live="polite"> {/* Back to an instant for the server: a local date-time parses as local time. */} value: {JSON.stringify(value)} → {value ? new Date(value).toISOString() : 'null'} </Text> </div> );}value and defaultValue take any ISO 8601 date or date-time. One with Z or an offset, such
as an instant from an API, is shown in the user’s time zone. Seconds are dropped.
onValueChange always gives the local shape in the table above.
To send an instant back, new Date(value).toISOString() — a local date-time parses as local
time. A date alone, new Date('2026-10-16'), parses as UTC midnight, so keep dates as strings.
A range
Section titled “A range”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DateRangePicker, Text, type DateRangePickerProps } from '@arun-dev/ui';
type Range = NonNullable<DateRangePickerProps['value']>;
export default function DateRangePickerBasics() { const [trip, setTrip] = useState<Range>({ start: '2026-10-12', end: '2026-10-16' });
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)' }}> <span id="trip-label">Trip dates</span> {/* Up to 14 days, both ends counted. Two months side by side, wrapping on a phone. */} <DateRangePicker aria-labelledby="trip-label" value={trip} onValueChange={setTrip} maxDays={14} months={2} /> <Text size="sm" color="secondary" aria-live="polite"> value: {JSON.stringify(trip)} </Text> </div> );}DateRangePicker has two fields, named “Start date” and “End date”, and a range calendar: the
first press starts the range, the second ends it, in either order. Name the group with
aria-label or aria-labelledby.
maxDays limits its length, both ends counted: maxDays={14} allows the 12th to the 25th.
Once the first day is picked, days further away are disabled in the calendar, and the end
field’s max follows the start, so the browser rejects a typed end that is too far.
The fields keep each other in step: the end can’t be before the start, nor the start after the end.
A range with times
Section titled “A range with times”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DateRangePicker, Text, type DateRangePickerProps } from '@arun-dev/ui';
type Range = NonNullable<DateRangePickerProps['value']>;
export default function DateRangePickerTime() { // Each end has its own time. A picked range keeps them; new ends get 00:00 and 23:59. const [booking, setBooking] = useState<Range>({ start: '2026-10-12T14:00', end: '2026-10-14T11:00', });
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)' }}> <span id="booking-label">Check-in and check-out</span> <DateRangePicker aria-labelledby="booking-label" withTime labels={{ start: 'Check-in', end: 'Check-out' }} value={booking} onValueChange={setBooking} /> <Text size="sm" color="secondary" aria-live="polite"> value: {JSON.stringify(booking)} </Text> </div> );}With withTime, each end has its own time, set in its field or in the popup’s Start time
and End time fields under the range calendar. The popup stays open after a range is picked,
until Done. Picking keeps both times; an end with none yet gets 00:00 for the start and 23:59
for the end, so the range covers whole days.
A limit in hours
Section titled “A limit in hours”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useState } from 'react';import { DateRangePicker, Text, type DateRangePickerProps } from '@arun-dev/ui';
type Range = NonNullable<DateRangePickerProps['value']>;
/** Whole hours between two local date-times. */const hoursBetween = (start: string, end: string) => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 3_600_000);
export default function DateRangePickerHours() { const [booking, setBooking] = useState<Range>({ start: '2026-10-16T10:00', end: '2026-10-17T22:00', });
return ( <div style={{ display: 'grid', gap: 'var(--space-2xs)' }}> <span id="hall-label">Community hall booking</span> {/* At most 40 hours: days past the reach are disabled, and a later end is pulled back. */} <DateRangePicker aria-labelledby="hall-label" withTime maxHours={40} labels={{ start: 'From', end: 'Until' }} value={booking} onValueChange={setBooking} /> <Text size="sm" color="secondary" aria-live="polite"> {booking.start && booking.end ? `${hoursBetween(booking.start, booking.end)} hours booked` : 'Choose both ends'} </Text> </div> );}maxHours, with withTime, caps the time from start to end: a hall booked for at most 40
hours. Once the first day is picked, the calendar disables the days 40 hours can’t reach from the
start’s time — from 10:00, the third day is the last. A pick whose end time would run past the
cap is pulled back to exactly start + 40 hours, and the end field’s max follows the start, so a
typed or edited end past it is invalid and won’t submit. Moving the start, typed or in the popup, pulls an end that would
then run past the cap back to it, so the range never exceeds 40 hours.
Tags, marks and details
Section titled “Tags, marks and details”The pickers take Calendar’s day information too — getDayInfo, tagPriority, tagStyles,
onVisibleRangeChange, loading and dayProps — and pass it to the calendar in their popup:
holidays, festivals, birthdays and bookings marked on the days, and a details card on hover,
focus or a tap. See Calendar.
maxDays and maxHours together
Section titled “maxDays and maxHours together”They aren’t exclusive: set both, and the range keeps to whichever ends earlier. Each gives a
latest end — the last of maxDays days at 23:59, and maxHours after the start — and the earlier
of the two is used everywhere: the days the calendar disables, the end field’s max, and the end
a pick or a moved start is pulled back to. They never contradict; one is simply stricter.
| Start | Props | Latest end | The limit that applies |
|---|---|---|---|
| Fri 16 Oct 10:00 | maxDays={1} maxHours={40} | Fri 16 Oct 23:59 | maxDays |
| Fri 16 Oct 10:00 | maxDays={7} maxHours={40} | Sun 18 Oct 02:00 | maxHours |
| Fri 16 Oct 22:00 | maxDays={2} maxHours={30} | Sat 17 Oct 23:59 | maxDays |
Without withTime, maxHours is ignored and maxDays alone applies.
Limits
Section titled “Limits”minandmaxare set on the fields, so the browser validates a typed date, and the calendar disables the days outside them and won’t page past their months.isDateUnavailabledisables days in the calendar. A typed unavailable day makes the field invalid, with “This date is unavailable.” as its message (labels.unavailable), so a form won’t submit it.
The field holds its value, as an Input does, so a form submits it under its
name and react-hook-form binds it with register(), both ways:
const { register } = useForm({ defaultValues: { due: '2026-10-16' } });
<DatePicker aria-label="Due date" {...register('due', { required: true })} />
<DateRangePicker aria-label="Trip dates" startInputProps={register('from')} endInputProps={register('to')}/>setValue and reset() reach the field, and the calendar opens on whatever it holds. Inside a
Field, Field.Control render={<DatePicker />} connects the label,
description and invalid state.
Playground
Section titled “Playground”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
// value: null
<DatePicker
aria-label="Date"
value={value}
onValueChange={setValue}
/>Keyboard
Section titled “Keyboard”| Where | Key | Does |
|---|---|---|
| Field | Arrows, digits | The browser’s: moves between and changes day, month, year |
| Button | Enter, Space | Opens the calendar on the chosen day, or today |
| Calendar | Arrows, Page Up/Down, … | See Calendar |
| Calendar | Enter, Space | Picks the day, closes, and returns focus to the button |
| Calendar | Esc | Closes without picking, and returns focus to the button |
Accessibility
Section titled “Accessibility”- The button is named “Choose date”, then the chosen date in full, such as “Choose date, Friday, October 16, 2026”, so a screen reader hears what’s set without opening it.
- The popup is a non-modal dialog named “Choose date”. Focus moves into the calendar on open and back to the button on close.
- Label the field as any input: a
<label>, a Field, oraria-label.
DatePicker
Section titled “DatePicker”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | Controlled: the date, ISO 8601. A value with Z or an offset is shown in local time. Provide onValueChange alongside it. | |
defaultValue | string | null | Initial value when uncontrolled. Read once, at mount; form.reset() returns to it. Uncontrolled, register() binds the input. | |
onValueChange | (value: string | null) => void | Called with YYYY-MM-DD, or YYYY-MM-DDTHH:mm with withTime, typed or picked; null once cleared. | |
withTime | boolean | false | Adds the time: each input becomes datetime-local, each value YYYY-MM-DDTHH:mm, and the popup gets time fields and Done, staying open after a pick. |
min | string | The earliest date, ISO 8601. Set on the input, so the browser validates a typed date, and kept by the calendar. | |
max | string | The latest date, ISO 8601. Set on the input and kept by the calendar. | |
isDateUnavailable | (date: string) => boolean | Days that cannot be picked. A typed one makes the input invalid, with labels.unavailable as its message. | |
disabled | boolean | false | Disables the inputs and the calendar button. |
months | number | 1 | How many months the calendar shows side by side. |
locale | string | The locale for the calendar and the button's name. The inputs' format is the browser's. | |
firstDayOfWeek | number | 0 for Sunday to 6 for Saturday. Defaults to the locale's. | |
open | boolean | Controlled: whether the calendar is open. Provide onOpenChange alongside it. | |
defaultOpen | boolean | false | Initially open when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called when the calendar opens or closes. | |
labels | { choose?; dialog?; start?; end?; unavailable?; time?; startTime?; endTime? } | Names for the calendar button and popup, the range inputs, the time fields in the popup, and the unavailable message, in English unless replaced. | |
getDayInfo | (date: string) => { tags?; description?; details? } | What each day is: tags for marks and styling, a description added to its spoken name, and details for the card. A lookup in data already loaded. Optional. | |
tagPriority | string[] | ['booked', 'holiday', 'festival', 'birthday'] | The tags that may mark a day, the first winning: a day shows one mark, for its tag highest here. Other tags style, but mark nothing. |
tagStyles | Record<string, { color?: string; mark?: 'dot' | 'ring' | 'star' | 'diamond' | 'bar' | 'strike' | 'none' }> | The colour and shape of each tag’s mark. Tags left out keep the built-in look, from the --calendar-tag-* tokens. | |
onVisibleRangeChange | (range: { start: string; end: string }) => void | Called with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here. | |
loading | boolean | false | Day information is loading: the grids are aria-busy, and the marks wait. |
dayProps | (date, info) => button attributes | Extra attributes for each day’s button, from its date and tags: per-day styling. | |
captionLayout | 'label' | 'dropdown' | 'label' | dropdown shows month and year selects above the days, to jump to a far-off month such as a date of birth. Years run from min's to max's, or 100 years back to 10 ahead. |
doneLabel | string | 'Done' | The text of the button that closes the popup, shown with withTime. |
calendarLabels | { previous?: string; next?: string } | Names for the calendar's Previous and Next buttons. | |
…rest | input props | name, id, required, aria-*, a Field’s attributes and a register() ref go on the input. className goes on the box. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. |
DateRangePicker
Section titled “DateRangePicker”| Prop | Type | Default | Description |
|---|---|---|---|
value | { start: string | null; end: string | null } | null | Controlled: the range, each end ISO 8601. Either end may be empty while it is typed. Provide onValueChange alongside it. | |
defaultValue | { start: string | null; end: string | null } | null | Initial range when uncontrolled. Read once, at mount; form.reset() returns to it. | |
onValueChange | (value: { start; end }) => void | Called whenever either end is typed, or once with both when a range is picked. | |
maxDays | number | The most days the range may cover, both ends included. The calendar stops a pick going further, the end input’s max stops a typed one, and a moved start pulls a later end back. With maxHours too, whichever ends earlier applies. | |
maxHours | number | With withTime, the most hours from start to end, such as 40 for a booking. The calendar disables days out of reach, a picked end or one a moved start passes is pulled back to start + maxHours, and the end input’s max stops a typed one. With maxDays too, whichever ends earlier applies. Ignored without withTime. | |
startInputProps | input props | For the start input: its name, or everything register('from') returns. | |
endInputProps | input props | For the end input: its name, or everything register('to') returns. | |
required | boolean | Both inputs must be filled. | |
id | string | The start input's id — what a Field's label points at. | |
aria-describedby | string | Set on both inputs, so a Field’s description and error describe each end. | |
aria-invalid | boolean | 'true' | 'false' | Set on both inputs, so a Field marks each end invalid. | |
withTime | boolean | false | Adds the time: each input becomes datetime-local, each value YYYY-MM-DDTHH:mm, and the popup gets time fields and Done, staying open after a pick. |
min | string | The earliest date, ISO 8601. Set on the input, so the browser validates a typed date, and kept by the calendar. | |
max | string | The latest date, ISO 8601. Set on the input and kept by the calendar. | |
isDateUnavailable | (date: string) => boolean | Days that cannot be picked. A typed one makes the input invalid, with labels.unavailable as its message. | |
disabled | boolean | false | Disables the inputs and the calendar button. |
months | number | 1 | How many months the calendar shows side by side. |
locale | string | The locale for the calendar and the button's name. The inputs' format is the browser's. | |
firstDayOfWeek | number | 0 for Sunday to 6 for Saturday. Defaults to the locale's. | |
open | boolean | Controlled: whether the calendar is open. Provide onOpenChange alongside it. | |
defaultOpen | boolean | false | Initially open when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called when the calendar opens or closes. | |
labels | { choose?; dialog?; start?; end?; unavailable?; time?; startTime?; endTime? } | Names for the calendar button and popup, the range inputs, the time fields in the popup, and the unavailable message, in English unless replaced. | |
getDayInfo | (date: string) => { tags?; description?; details? } | What each day is: tags for marks and styling, a description added to its spoken name, and details for the card. A lookup in data already loaded. Optional. | |
tagPriority | string[] | ['booked', 'holiday', 'festival', 'birthday'] | The tags that may mark a day, the first winning: a day shows one mark, for its tag highest here. Other tags style, but mark nothing. |
tagStyles | Record<string, { color?: string; mark?: 'dot' | 'ring' | 'star' | 'diamond' | 'bar' | 'strike' | 'none' }> | The colour and shape of each tag’s mark. Tags left out keep the built-in look, from the --calendar-tag-* tokens. | |
onVisibleRangeChange | (range: { start: string; end: string }) => void | Called with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here. | |
loading | boolean | false | Day information is loading: the grids are aria-busy, and the marks wait. |
dayProps | (date, info) => button attributes | Extra attributes for each day’s button, from its date and tags: per-day styling. | |
captionLayout | 'label' | 'dropdown' | 'label' | dropdown shows month and year selects above the days, to jump to a far-off month such as a date of birth. Years run from min's to max's, or 100 years back to 10 ahead. |
doneLabel | string | 'Done' | The text of the button that closes the popup, shown with withTime. |
calendarLabels | { previous?: string; next?: string } | Names for the calendar's Previous and Next buttons. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
- The browser draws the field, so its look varies a little by browser. Chrome’s own calendar icon is hidden in favour of the button; in Firefox and Safari a click on the field may also open the browser’s picker. On phones, the browser’s picker is the usual way in.
- Import the
calendaranddate-pickerstylesheets, or the full@arun-dev/uiCSS. - Restyle with the
--date-picker-*and--calendar-*tokens.