Composition
A date picker is built from Popover and
Calendar — there is no DatePicker
component. Both already own their half of the job: Popover the disclosure,
positioning, dismissal and focus return; Calendar the grid, its keyboard model
and every selection mode. What is left is the join between them, which differs
enough per form that a component would have to guess.
<Popover>
<Popover.Trigger>…</Popover.Trigger>
<Popover.Content classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar />
</Popover.Content>
</Popover>
tk-datepicker-panel is the one class the design system supplies. Popover's
content box is a text bubble — capped at 296px, with its own padding — so an
unmodified panel clips a calendar. The class lifts the cap, drops the padding,
and removes the calendar's standalone border. It is opt-in because nothing emits
it for you; applying it is what keeps every picker in the system looking the
same.
With a text input
For a typable field, useDatePicker is the join: it holds the
value, masks the field, turns a completed date into a Date, and writes a
picked day back as text. It renders nothing, so every element below is still
yours to place.
function InputDemo() {
const picker = useDatePicker();
return (
<div className="mx-auto w-72">
<Field>
<Field.Label>Departure</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field placeholder="dd/mm/yyyy" {...picker.inputProps} />
<Input.ClearButton />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar {...picker.calendarProps} />
</Popover.Content>
</Popover>
</Field>
</div>
);
}
render(<InputDemo />);
Each group is an ordinary object: spread it, override one key of it, or leave it
out. Input's anatomy is untouched — the trigger is just another part, and
order decides placement: anything after the field lands at its inline end, which
is where Input.ClearButton already sits. tk-input-action is Input's own hook
for a consumer-placed control, so the trigger is that button's twin at every
size rather than a second set of rules that can drift.
Bounds
min and max reach both halves from one pair of dates: the grid takes them as
Dates, the mask as ISO strings. Bound only the grid and a date the calendar
rejects can still be typed — here 25/08/2026 clamps to the 20th instead.
function BoundsDemo() {
const picker = useDatePicker({
min: new Date(2026, 7, 10),
max: new Date(2026, 7, 20),
});
return (
<div className="mx-auto w-72">
<Field>
<Field.Label>Departure</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field placeholder="dd/mm/yyyy" {...picker.inputProps} />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar {...picker.calendarProps} />
</Popover.Content>
</Popover>
<Field.Description>10-20 August 2026 — try typing 25/08/2026</Field.Description>
</Field>
</div>
);
}
render(<BoundsDemo />);
Keyboard
inputProps.onKeyDown opens the panel on ArrowDown, which is the affordance
that makes the calendar reachable without leaving the keyboard. It is one line
when you wire a field yourself:
onKeyDown={event => {
if (event.key !== 'ArrowDown') return;
event.preventDefault();
setOpen(true);
}}
Drop it when the field is read-only and the trigger is the only way in. The
panel is not modal: it does not trap focus, and Escape or an outside click
dismisses it.
Localization
A picker has two halves to localize, and they have to agree. The grid takes a
locale object from react-day-picker/locale — import only the ones you use,
so only those are bundled. The field is the other half: delimiter sets both
the separator the mask inserts as you type and the one written back on select,
so gg.aa.yyyy is a mask that accepts what the field shows.
const LOCALES = {
en: { locale: enUS, delimiter: '/', placeholder: 'dd/mm/yyyy', label: 'Departure' },
tr: { locale: tr, delimiter: '.', placeholder: 'gg.aa.yyyy', label: 'Gidiş tarihi' },
};
function LocaleDemo() {
const [code, setCode] = React.useState('tr');
const active = LOCALES[code];
const picker = useDatePicker({ delimiter: active.delimiter });
return (
<div className="mx-auto flex w-72 flex-col gap-3">
<div className="flex justify-center gap-1">
{Object.keys(LOCALES).map(entry => (
<Button key={entry} size="small" variant={entry === code ? 'primary' : 'neutral'} onClick={() => setCode(entry)}>
{entry}
</Button>
))}
</div>
<Field key={code}>
<Field.Label>{active.label}</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field placeholder={active.placeholder} {...picker.inputProps} />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar {...picker.calendarProps} locale={active.locale} />
</Popover.Content>
</Popover>
</Field>
</div>
);
}
render(<LocaleDemo />);
The day order is the field's own convention, not the grid's: useDatePicker
writes dd<delimiter>mm<delimiter>yyyy, which suits both examples above. A
locale that puts the month first needs format as well, and a mask whose
datePattern matches it — see Input for the mask
vocabulary.
Calendar carries the rest of the grid's localization: firstDayOfWeekIndex
overrides the locale's own first column, numerals switches the digit system,
and dir="rtl" mirrors the grid. See
Calendar → Localization.
Validation
A mask keeps the shape valid; a rule the mask cannot express is the app's own.
Mirror it on both halves — disabledWeekDays on the grid so the day cannot be
clicked, and the same check on picker.value so a typed one is caught.
function ValidationDemo() {
const picker = useDatePicker();
const value = picker.value;
const isWeekend = value && (value.getDay() === 0 || value.getDay() === 6);
const incomplete = picker.text !== '' && !value;
const invalid = Boolean(isWeekend || incomplete);
return (
<div className="mx-auto w-72">
<Field invalid={invalid}>
<Field.Label>Delivery date</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field placeholder="dd/mm/yyyy" {...picker.inputProps} />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar {...picker.calendarProps} disabledWeekDays={[0, 6]} />
</Popover.Content>
</Popover>
{invalid ? <Field.ErrorMessage>{incomplete ? 'Finish the date' : 'Weekends are not available'}</Field.ErrorMessage> : <Field.Description>Weekdays only</Field.Description>}
</Field>
</div>
);
}
render(<ValidationDemo />);
Presets
Calendar's footer takes any node, so shortcuts live inside the panel rather
than beside it. picker.setValue is the whole handler: the field text follows,
and the grid scrolls to a date that lands in another month.
const PRESETS = [
{ label: 'Today', days: 0 },
{ label: 'Tomorrow', days: 1 },
{ label: 'In a week', days: 7 },
];
function PresetsDemo() {
const picker = useDatePicker();
const pick = days => {
const next = new Date();
next.setDate(next.getDate() + days);
picker.setValue(next);
};
return (
<div className="mx-auto w-72">
<Field>
<Field.Label>Reminder</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field placeholder="dd/mm/yyyy" {...picker.inputProps} />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar
{...picker.calendarProps}
footer={
<div className="flex w-full flex-wrap justify-center gap-1" role="group" aria-label="Date presets">
{PRESETS.map(preset => (
<Button key={preset.label} variant="neutral" onClick={() => pick(preset.days)}>
{preset.label}
</Button>
))}
</div>
}
/>
</Popover.Content>
</Popover>
</Field>
</div>
);
}
render(<PresetsDemo />);
Sizes
size on the Input cascades to the field and the trigger. The calendar has
its own two-step scale, so pair them deliberately rather than assuming they
share a vocabulary.
const ICON = { small: 16, base: 20, large: 24 };
function SizedPicker({ size }) {
const picker = useDatePicker({ defaultValue: new Date(2026, 7, 15) });
return (
<Popover {...picker.popoverProps}>
<Input size={size}>
<Input.Field aria-label={size} {...picker.inputProps} />
<Popover.Trigger aria-label={'Select ' + size + ' date'} classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={ICON[size]} height={ICON[size]} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar {...picker.calendarProps} size={size === 'small' ? 'small' : 'base'} />
</Popover.Content>
</Popover>
);
}
function SizesDemo() {
return (
<div className="mx-auto flex w-72 flex-col gap-3">
{['small', 'base', 'large'].map(size => (
<SizedPicker key={size} size={size} />
))}
</div>
);
}
render(<SizesDemo />);
Range across two fields
A range is two values in one panel, so the hook — which owns a single date and
its field text — does not apply. Two fields and one panel is the usual booking
shape: the grid writes back into both.
Spar anchors the panel to its trigger, and there is one triggerRef per popover
— so use one trigger and let the other field open the same panel by click,
rather than two triggers where the panel would always hang off whichever mounted
last. The fields are read-only here because the grid owns the selection.
const format = value => (value ? String(value.getDate()).padStart(2, '0') + '/' + String(value.getMonth() + 1).padStart(2, '0') + '/' + value.getFullYear() : '');
function TwoFieldRangeDemo() {
const [range, setRange] = React.useState();
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<div className="flex items-end justify-center gap-3">
<div className="w-40">
<Field>
<Field.Label>From</Field.Label>
<Input>
<Input.Field readOnly placeholder="dd/mm/yyyy" value={format(range && range.from)} onClick={() => setOpen(true)} />
</Input>
</Field>
</div>
<div className="w-40">
<Field>
<Field.Label>To</Field.Label>
<Input>
<Input.Field readOnly placeholder="dd/mm/yyyy" value={format(range && range.to)} onClick={() => setOpen(true)} />
<Popover.Trigger aria-label="Select dates" classNames={{ root: 'tk-input-action' }}>
<DateRangeIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
</Field>
</div>
</div>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar
mode="range"
value={range}
numberOfMonths={2}
defaultMonth={new Date(2026, 7, 1)}
onValueChange={next => {
setRange(next);
if (next && next.from && next.to) setOpen(false);
}}
/>
</Popover.Content>
</Popover>
);
}
render(<TwoFieldRangeDemo />);
Apply before committing
Calendar commits on the click — onValueChange fires the moment a day is
picked, and there is no Apply button to wait for. When the form needs an
explicit confirmation step, hold two values: bind the grid to a draft, and let
the panel's own button promote the draft to the value the field shows. That is
two values again, so this one is wired by hand rather than through the hook.
const format = value => (value ? String(value.getDate()).padStart(2, '0') + '/' + String(value.getMonth() + 1).padStart(2, '0') + '/' + value.getFullYear() : '');
function ConfirmDemo() {
const [date, setDate] = React.useState();
const [draft, setDraft] = React.useState();
const [open, setOpen] = React.useState(false);
const toggle = next => {
if (next) setDraft(date);
setOpen(next);
};
const pending = draft && draft.getTime() !== (date ? date.getTime() : 0);
return (
<div className="mx-auto w-72">
<Field>
<Field.Label>Appointment</Field.Label>
<Popover open={open} onOpenChange={toggle}>
<Input>
<Input.Field readOnly placeholder="dd/mm/yyyy" value={format(date)} onClick={() => toggle(true)} />
<Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
<CalendarIconOutlinedRounded width={20} height={20} />
</Popover.Trigger>
</Input>
<Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar value={draft} onValueChange={setDraft} />
<div className="flex justify-end gap-1 p-3">
<Button variant="neutral" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button disabled={!pending} onClick={() => { setDate(draft); setOpen(false); }}>
Apply
</Button>
</div>
</Popover.Content>
</Popover>
<Field.Description>{pending ? 'Pending: ' + format(draft) : 'Picking a day does not commit it'}</Field.Description>
</Field>
</div>
);
}
render(<ConfirmDemo />);
Re-seeding the draft on open is what makes Cancel — and a dismissal by Escape
or an outside click — a plain close: the next open starts from the applied value
again, so an abandoned edit cannot leak into the field. Note the buttons sit
beside the calendar rather than in Calendar's footer, which the engine
renders as a live region.
Range in one panel
mode behaves no differently inside a popover. A range is not finished on the
first click, so this one does not close on select.
function RangeDemo() {
const [range, setRange] = React.useState();
const label = range && range.from ? (range.to ? range.from.toLocaleDateString() + ' - ' + range.to.toLocaleDateString() : range.from.toLocaleDateString()) : 'Pick a range';
return (
<div className="flex justify-center">
<Popover>
<Popover.Trigger as={Button} variant="neutral">
<DateRangeIconOutlinedRounded width={20} height={20} />
{label}
</Popover.Trigger>
<Popover.Content align="start" classNames={{ root: 'tk-datepicker-panel' }}>
<Calendar mode="range" value={range} onValueChange={setRange} numberOfMonths={2} />
</Popover.Content>
</Popover>
</div>
);
}
render(<RangeDemo />);
Inline
Without the popover there is nothing to compose — use
Calendar on its own.
API Reference
There is no DatePicker component, so there is no component API. What the design
system supplies is one hook and two classes; every other prop comes from the
components the pattern is built out of:
- Popover —
open, defaultOpen, onOpenChange,
side, align, and the dismiss callbacks.
- Calendar —
mode, value, onValueChange,
month, onMonthChange, minDate, maxDate, headerType, size, and the
rest of the grid surface.
- Input — the field, its
mask, and the in-field
parts.
useDatePicker
useDatePicker(options?) holds the value and the field text for a single-date
picker. It renders nothing and owns no anatomy.
| Option | Type | Default | What it does |
|---|
| min | Date | - | Earliest selectable date. Bounds the grid and the mask. |
| max | Date | - | Latest selectable date. Bounds the grid and the mask. |
| defaultValue | Date | - | Initial value, uncontrolled. |
| delimiter | string | / | Separator between the day, month and year blocks, in the field and mask. |
| format | (value: Date) => string | dd/mm/yyyy | Renders the picked date into the field. |
| onValueChange | (value?: Date) => void | - | Fires on every change, from either half. |
| Returns | Type | What it is |
|---|
| value | Date | undefined | The picked date; undefined while the field is empty or half-typed. |
| text | string | The field's text, masked. |
| open | boolean | Whether the panel is open. |
| setValue | (value?: Date) => void | Drives the picker from outside — a preset, a reset, a restored form value. |
| popoverProps | object | open / onOpenChange, for Popover. |
| inputProps | object | mask, value, onValueChange, onKeyDown, for Input.Field. |
| calendarProps | object | value, minDate, maxDate, onValueChange, for Calendar. |
Styling hooks
| Class | Applies to | What it does |
|---|
| tk-datepicker-panel | Popover.Content | Lifts the bubble's width cap and padding so a calendar fits, and drops the grid's border. |
| tk-input-action | Popover.Trigger | Owned by Input, not by this pattern — the opt-in hook for any control a consumer places in a field. Makes it Input.ClearButton's twin at every size. Does nothing outside an Input. |