Skip to content

DatePicker

  • 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.

Type it, or pick it from the calendar.

value: "2026-10-16"
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>
);
}

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.

ValueShapeFor example
A dateYYYY-MM-DD'2026-10-16'
With withTimeYYYY-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.

value: "2026-10-16T09:30"
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.

value: "1990-06-15T07:30"
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.

Next anniversary: 2027-02-14, 26 years
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>
);
}
value: "2026-10-16T08:00:00Z" → 2026-10-16T08:00:00.000Z
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.

Trip dates
value: {"start":"2026-10-12","end":"2026-10-16"}
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.

Check-in and check-out
value: {"start":"2026-10-12T14:00","end":"2026-10-14T11:00"}
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.

Community hall booking
36 hours booked
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.

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.

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.

StartPropsLatest endThe limit that applies
Fri 16 Oct 10:00maxDays={1} maxHours={40}Fri 16 Oct 23:59maxDays
Fri 16 Oct 10:00maxDays={7} maxHours={40}Sun 18 Oct 02:00maxHours
Fri 16 Oct 22:00maxDays={2} maxHours={30}Sat 17 Oct 23:59maxDays

Without withTime, maxHours is ignored and maxDays alone applies.

  • min and max are 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.
  • isDateUnavailable disables 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.

// value: null
<DatePicker
  aria-label="Date"
  value={value}
  onValueChange={setValue}
/>
WhereKeyDoes
FieldArrows, digitsThe browser’s: moves between and changes day, month, year
ButtonEnter, SpaceOpens the calendar on the chosen day, or today
CalendarArrows, Page Up/Down, …See Calendar
CalendarEnter, SpacePicks the day, closes, and returns focus to the button
CalendarEscCloses without picking, and returns focus to the button
  • 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, or aria-label.
DatePicker props
PropTypeDefaultDescription
valuestring | nullControlled: the date, ISO 8601. A value with Z or an offset is shown in local time. Provide onValueChange alongside it.
defaultValuestring | nullInitial value when uncontrolled. Read once, at mount; form.reset() returns to it. Uncontrolled, register() binds the input.
onValueChange(value: string | null) => voidCalled with YYYY-MM-DD, or YYYY-MM-DDTHH:mm with withTime, typed or picked; null once cleared.
withTimebooleanfalseAdds 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.
minstringThe earliest date, ISO 8601. Set on the input, so the browser validates a typed date, and kept by the calendar.
maxstringThe latest date, ISO 8601. Set on the input and kept by the calendar.
isDateUnavailable(date: string) => booleanDays that cannot be picked. A typed one makes the input invalid, with labels.unavailable as its message.
disabledbooleanfalseDisables the inputs and the calendar button.
monthsnumber1How many months the calendar shows side by side.
localestringThe locale for the calendar and the button's name. The inputs' format is the browser's.
firstDayOfWeeknumber0 for Sunday to 6 for Saturday. Defaults to the locale's.
openbooleanControlled: whether the calendar is open. Provide onOpenChange alongside it.
defaultOpenbooleanfalseInitially open when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled 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.
tagPrioritystring[]['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.
tagStylesRecord<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 }) => voidCalled with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here.
loadingbooleanfalseDay information is loading: the grids are aria-busy, and the marks wait.
dayProps(date, info) => button attributesExtra 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.
doneLabelstring'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.
…restinput propsname, id, required, aria-*, a Field’s attributes and a register() ref go on the input. className goes on the box.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
DateRangePicker props
PropTypeDefaultDescription
value{ start: string | null; end: string | null } | nullControlled: 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 } | nullInitial range when uncontrolled. Read once, at mount; form.reset() returns to it.
onValueChange(value: { start; end }) => voidCalled whenever either end is typed, or once with both when a range is picked.
maxDaysnumberThe 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.
maxHoursnumberWith 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.
startInputPropsinput propsFor the start input: its name, or everything register('from') returns.
endInputPropsinput propsFor the end input: its name, or everything register('to') returns.
requiredbooleanBoth inputs must be filled.
idstringThe start input's id — what a Field's label points at.
aria-describedbystringSet on both inputs, so a Field’s description and error describe each end.
aria-invalidboolean | 'true' | 'false'Set on both inputs, so a Field marks each end invalid.
withTimebooleanfalseAdds 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.
minstringThe earliest date, ISO 8601. Set on the input, so the browser validates a typed date, and kept by the calendar.
maxstringThe latest date, ISO 8601. Set on the input and kept by the calendar.
isDateUnavailable(date: string) => booleanDays that cannot be picked. A typed one makes the input invalid, with labels.unavailable as its message.
disabledbooleanfalseDisables the inputs and the calendar button.
monthsnumber1How many months the calendar shows side by side.
localestringThe locale for the calendar and the button's name. The inputs' format is the browser's.
firstDayOfWeeknumber0 for Sunday to 6 for Saturday. Defaults to the locale's.
openbooleanControlled: whether the calendar is open. Provide onOpenChange alongside it.
defaultOpenbooleanfalseInitially open when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled 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.
tagPrioritystring[]['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.
tagStylesRecord<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 }) => voidCalled with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here.
loadingbooleanfalseDay information is loading: the grids are aria-busy, and the marks wait.
dayProps(date, info) => button attributesExtra 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.
doneLabelstring'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.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything 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 calendar and date-picker stylesheets, or the full @arun-dev/ui CSS.
  • Restyle with the --date-picker-* and --calendar-* tokens.