DatePicker
A date field with two ways in. Type, and the digits fill segments that jump on their own and turn with the arrow keys — the behaviour of the native date input, over a mask that follows the country. Or open the panel and pick from the same Calendar this library ships inline. What comes out is always a plain ISO string with no time zone in it, so Temporal.PlainDate.from(value) costs nothing and the instant is composed where the zone is actually known.
- Stable
- React 19
- One masked input
- ISO strings
- Temporal-ready
Escribe los dígitos o abre el calendario.
<DatePicker name="salida" label="Fecha de salida" locale="es-EC" labels={{ previous: 'Mes anterior', next: 'Mes siguiente', today: 'Hoy', calendar: 'Abrir calendario', }}/>The value is an ISO string
Section titled “The value is an ISO string”There are three shapes, and each one has exactly one spelling on the wire:
| Shape | How you ask for it | The value |
|---|---|---|
| One day | the default | "2026-08-21" |
| One minute | granularity="minute" |
"2026-08-21T09:30" |
| A range | mode="range" |
{ start, end }, each half one of those strings or null |
There is never a time zone in it. Those strings are exactly what
Temporal.PlainDate#toString() and Temporal.PlainDateTime#toString() produce, which is
the whole point: Temporal.PlainDate.from(value) is free, and nothing in the value has to
be undone before it can be used. An empty field is null; the empty half of a range is
null too.
The arithmetic behind the grid runs on globalThis.Temporal when the engine has it and on
UTC Date maths when it does not — the same answers either way, because a plain date has
no instant to disagree about. See Temporal and time zones.
The four labels are required
Section titled “The four labels are required”previous, next, today and calendar are required in the type, not optional strings
with English defaults. It is
ComboBox’s rationale: out of the box
the component renders two icon-only buttons and a role="dialog" whose only accessible
names are those strings, so optional-and-undefaulted would ship a default configuration
that fails an audit, and defaulted-in-English would ship a Spanish product an English
announcement nobody notices. times joins them, required by the type exactly when
granularity="minute" puts a list of times on the page.
Everything else the component says comes from Intl and needs no prop: month names,
weekday initials, the long cell labels, the mask’s field order and its separators.
The locale decides what Intl can decide
Section titled “The locale decides what Intl can decide”locale is a BCP 47 tag. Left out, the platform’s is resolved —
new Intl.DateTimeFormat().resolvedOptions().locale. Pass it explicitly under SSR, or
the server and the browser resolve two different masks and the field changes under
hydration.
What the locale settles:
- The mask.
Intl.DateTimeFormat#formatToPartsgives the field order and the separators, soes-ECwrites21/08/2026,en-USwrites08/21/2026andde-DEwrites21.08.2026. Only the structure is taken: the digits are always Latin and always zero-padded to the field width, because a segment that changes width would move the caret while you type into it. - The range separator, from
formatRangeToParts— taken exactly as emitted, spaces included, because some engines pad it and some do not. - Month and weekday names in the panel, and their capitalisation — always Gregorian, whatever calendar the locale would pick — and the digits of its days and years.
- The first column of the week, through
Intl.Locale’s week info.weekStart(1 = Monday … 7 = Sunday, Temporal’s numbering) overrides it. Chrome reads the week info from 99 and Safari from 17; where it is missing — Safari 16.4 at the floor — the fallback is Monday.
A mask of your own
Section titled “A mask of your own”format replaces the locale’s mask with a pattern, as a TR35 subset:
| Token | Field |
|---|---|
d, dd |
day |
M, MM |
month |
yyyy |
year |
H, HH |
hour |
mm |
minute |
Anything that is not a letter is a literal. Anything else throws — yy, MMM and a
included — so the mistake surfaces in development rather than as a field nobody can fill.
So does a pattern that does not hold exactly what the granularity needs, each field once:
day, month and year at day, those three plus hour and minute at minute.
The single-letter forms still make two-character segments: the pattern picks the order and
the separators, never the width. The locale keeps naming the months either way.
<DatePicker format="yyyy-MM-dd" locale="es-EC" … />Ranges
Section titled “Ranges”mode="range" doubles the mask around the locale’s range separator inside the one
input — 21/08/2026 – 31/08/2026 — and turns the panel’s month into a range grid.
The two halves are independent. Each one commits the moment its own segments spell a
complete, possible, allowed date, so onValueChange fires with { start, end: null }
first and with both halves after. In the panel it is the calendar’s rule: the first click
sets start, the second fills end ordered so start is never after it, a third starts
again, and the band that follows the pointer between them is paint and never fires
anything.
Picking end in the panel is what closes it.
Date and time
Section titled “Date and time”granularity="minute" adds HH:mm to the mask and a scrolling list of times to the
panel, timeStep minutes apart (30 by default, pulled into 1–60 like TimePicker’s step).
The value becomes "2026-08-21T09:30".
A date alone is not a moment, so the two picks are staged: clicking a day leaves the
panel open and moves focus into the list of times; picking a time commits and closes.
While no day has been chosen the list wears data-waiting and its rows are
aria-disabled, drawn in the muted grey: a time waits for its day, and the panel never
guesses one — a guess could be a day min, max or isDateDisabled refuses.
mode="range" with granularity="minute" is a reserved combination: the types reject
the pair and the component throws on it. A range of moments has no mask and no panel yet,
and shipping half of one under a name that promises the whole thing is the refusal
Tabs makes for a half-linked set of items. Use one picker per end.
Bounds
Section titled “Bounds”| Prop | What it does |
|---|---|
min / max |
ISO dates. Cells outside refuse selection and are announced aria-disabled; a date typed past a bound marks the field invalid and commits null. |
isDateDisabled |
(iso: string) => boolean, for what neither bound can express — weekends, holidays, a booked day. Called for every painted cell and every date the field completes, so keep it cheap. |
A blocked day is colour plus a line through it, never colour alone. A date the field
refuses — 31 February, a day past a bound, one isDateDisabled vetoed — sets
aria-invalid on the input and commits null: onValueChange fires with null and the
form holds "", so required and your schema see an empty field rather than the date that
was there before, and the form never posts a date the user is no longer looking at. The
text stays as typed, ready to be corrected. In a range the refused half is the one that
turns null, and an end typed before its start is refused the same way: the pair commits
as { start, end: null }. None of this takes the hint slot away from helper, and an
aria-invalid you pass yourself wins, including false.
The panel
Section titled “The panel”The panel is Popover with popover="manual": in the top layer,
placed by CSS anchor positioning, but never light-dismissed. The input sits outside the
panel, so the browser would count a click in the field as a click outside — closing the
panel and, with granularity="minute", throwing away a day picked while the time was
still to come. The picker closes it itself instead, with no document listener: when focus
leaves the whole control, and on Escape from the field or the panel. Scrolling never dismisses it, and because it stays in the React tree a
[data-theme] on an ancestor still resolves — the token bug a document.body portal
creates.
Anchor positioning and the top layer are probed together; below that tier the panel
falls back to an in-flow absolutely positioned box. The caveat on that path is the usual
one: an ancestor with overflow: hidden can clip it.
open, defaultOpen and onOpenChange are the controlled trio. Opening through open
leaves focus where the user left it — only the trigger and Alt+↓ move focus into the
panel, because only those two are a request to go there.
Keyboard
Section titled “Keyboard”In the field:
| Key | Behavior |
|---|---|
| Digits | Fill the active segment and advance as soon as no further digit could fit: 4 leaves a day field, 1 waits for a second digit. Past the maximum the segment starts over on the new digit. A segment still waiting for a digit commits nothing until it closes, so a year typed digit by digit reaches the form once, as 2026, never as 0002, 0020 and 0202 on the way. |
/ . - : space |
Any separator closes a segment still waiting for a digit and moves to the next, as the native date input does: 1/5/2026 is the first of May in es-EC. After a segment that closed on its own it does nothing. |
↑ / ↓ |
Turn the active segment by one, wrapping at the ends. The year clamps instead — 1 → 9999 is never what paging through birthdays meant. |
← / → |
Previous / next segment. |
Home / End |
First / last segment. |
Backspace / Delete |
Empties the active segment. |
Tab / Shift+Tab |
Segment to segment, leaving the field only from its last (or first) one — the native date input’s behaviour. |
Enter |
Closes a segment still waiting for a digit and commits it, then submits the form as usual. Leaving the field does the same. |
Alt+↓ |
Opens the panel and moves focus onto the grid’s tab stop. |
Pasting fills the whole field. ISO is read first, whatever order the mask writes —
2026-08-21, 2026-08-21T09:30 at minute granularity, 2026-08-21/2026-08-31 into a
range. Anything else is read one run of digits per field, so the separators decide:
1/5/2026 is the first of May in es-EC and the fifth of January in en-US. A run a
field cannot hold leaves that field empty rather than spilling into the next one, and text
with no separator at all, like 21082026, is typed in digit by digit.
In the panel:
| Key | Behavior |
|---|---|
← / → |
±1 day, mirrored under dir="rtl" as the APG grid pattern asks. |
↑ / ↓ |
±7 days. In the list of times, one row. |
Home / End |
Ends of the week. In the list of times, ends of the list. |
PageUp / PageDown |
±1 month; with Shift, ±1 year. |
Enter / Space |
Selects the day, or the time. |
Escape |
Closes the panel and returns focus to the input. Inside the month or year level it returns to the days first, so the panel closes on the next press. |
Tab |
Leaves. Focus leaving the whole control closes the panel. |
Temporal and time zones
Section titled “Temporal and time zones”The library imports no polyfill and takes no runtime dependency. It uses
globalThis.Temporal when the engine provides it and UTC-safe Date arithmetic when it
does not; for plain dates the two agree exactly, which is why there is no flag to set. If
your app wants Temporal on engines without it, load it once at the entry point:
// src/main.tsx — once, before anything renders.import 'temporal-polyfill/global';With or without it, the values these components emit are Temporal’s own grammar:
Temporal.PlainDate.from('2026-08-21');Temporal.PlainDateTime.from('2026-08-21T09:30');Composing the instant is the application’s job
Section titled “Composing the instant is the application’s job”Only the application knows which zone a date means, so the components never guess one:
const departure = Temporal.PlainDateTime .from('2026-08-21T09:30') .toZonedDateTime('America/Guayaquil');IANA zone names are cities, and not always the ones you expect. There is no
America/Quito — mainland Ecuador is America/Guayaquil — and Galápagos is its own
zone, Pacific/Galapagos, an hour behind.
A day and 24 hours are not the same thing
Section titled “A day and 24 hours are not the same thing”Across a daylight-saving change they part company, and Temporal is explicit about which one you asked for:
const eve = Temporal.ZonedDateTime.from('2026-10-24T20:00[Europe/Madrid]');eve.add({ days: 1 }); // 2026-10-25T20:00 — the same wall clock, 25 hours latereve.add({ hours: 24 }); // 2026-10-25T19:00 — 24 hours exactly, an hour earlier on the clockThe other half of that morning: 02:30 happens twice in Madrid. toZonedDateTime takes a
disambiguation option — "compatible" (the default), "earlier", "later" or
"reject" — and picking one is a product decision, not a default worth inheriting in
silence.
react-hook-form
Section titled “react-hook-form”Code
import { useState } from "react";import { useForm } from "react-hook-form";import { Button, DatePicker, Form } from "wayra-ui";
const LABELS = { previous: "Mes anterior", next: "Mes siguiente", today: "Hoy", calendar: "Abrir calendario",};
type Values = { estancia: { start: string; end: string } };
export function FormDemo() { const form = useForm<Values>({ // Both halves seeded: a range registers two paths, so `reset()` needs two // entries to go back to. defaultValues: { estancia: { start: "", end: "" } }, }); const [submitted, setSubmitted] = useState<string>();
return ( // Room for the panel on the in-flow fallback tier, as in the hero. <div style={{ paddingBottom: "23rem" }}> <Form form={form} className="hu-demo-col" onSubmit={(values) => { setSubmitted(JSON.stringify(values.estancia)); }} > {/* `name` is the whole wiring: the picker registers two anchors, `estancia.start` and `estancia.end`, and never puts a name on the visible input. */} <DatePicker mode="range" name="estancia" locale="es-EC" label="Estancia" helper="Las dos fechas viven en un solo campo." labels={LABELS} /> <Button type="submit" width="block"> Guardar </Button> {submitted && <p className="hu-readout">submitted: {submitted}</p>} </Form> </div> );}name is the whole wiring. Inside a Form the field registers itself and writes each
commit as a change on its anchor, the path a native input takes, so the form’s mode
decides when it validates. Focus leaving the whole control is its blur: it marks the field
touched and validates under onBlur and onTouched, while moving into the panel is not a
leave. Reads come back through a subscription scoped to the field, never a whole-form read.
The value never lives in one native control, so a registered anchor input carries
name instead — the same strategy ComboBox uses, with the
same three load-bearing details:
- It is a real text input, not
type="hidden".shouldFocusErrorcallsfocus()on the registered element, and a hidden input cannot take focus. This one is visually hidden and out of the tab order, and forwards its focus to the field you can see. - It owns
name; the visible input never has one. So a native submit posts the ISO string and never the masked text. - An empty field posts
"". A value and its absence have one shape on the wire.onValueChangestill reportsnull, because that is the value model, not the wire format.
The payload per shape:
| Shape | What the form holds |
|---|---|
| One day | name → "2026-08-21", or "" |
| One minute | name → "2026-08-21T09:30", or "" |
| A range | two paths, name.start and name.end, each a string — so the payload nests as { estancia: { start, end } }, the shape Slider posts |
Choose name or value, not both. Inside a Form, with a name, the store owns
the value — both halves of a range — and a value prop is ignored, so what the field
shows is always what the form sends (the rule ComboBox
follows). To control a form field from outside, call form.setValue(name, …). Outside a
Form, or under noRhf, value controls the field; with neither, the field keeps its own
state, seeded once by defaultValue.
defaultValue becomes the field’s default on mount, and only where the store is still
empty: the form stays pristine, a bare reset() comes back to it, and a real
defaultValues entry or reset(values) still wins. A form error fills the hint slot only
when you passed no error of your own. The field freezes while the form submits unless you
passed disabled, and useForm({ disabled: true }) disables it whatever you passed.
noRhf turns all of it off for one instance: nothing registers, and the form neither reads
nor writes the field.
Without a Form
Section titled “Without a Form”With a name and no Form around it the component renders <input type="hidden">
carriers — one, or two for a range — so a plain HTML <form> posts the same ISO strings
with no React involved. That is a step past ComboBox, which posts nothing outside a
Form.
Styling contract
Section titled “Styling contract”The root class plus its data-* attributes are the public API. The closed field
reproduces exactly from them, with no React:
Hand-written markup. Same box, same border, same glyph — what it does not have is the mask, the panel, and aria-expanded's other half.
Code
<div class="wayra-date-picker" data-variant="outline" data-color="primary" data-size="md" data-shape="rounded" data-granularity="day"> <input class="wayra-date-picker-input" type="text" inputmode="numeric" autocomplete="off" aria-haspopup="dialog" aria-label="Fecha de salida" value="21/08/2026"> <button class="wayra-date-picker-trigger" type="button" tabindex="-1" aria-label="Abrir calendario" aria-expanded="false"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2.5"/><path d="M8 3v4"/><path d="M16 3v4"/><path d="M3 10h18"/></svg> </button></div>| Attribute | Values |
|---|---|
data-variant |
outline (default), filled, soft, ghost |
data-color |
the eight roles; primary when omitted |
data-size |
xs, sm, md, lg, xl |
data-shape |
rounded, rectangle — no pill, the narrowing ComboBox makes, because the trigger needs a straight edge to sit against |
data-range |
"true" while mode="range"; absent otherwise |
data-granularity |
day or minute, always present |
data-open |
"true" while the panel is open; absent otherwise |
The parts are .wayra-date-picker-input, -trigger, -anchor (the visually hidden
registered input), -panel — which also wears .wayra-popover .wayra-overlay — and,
under granularity="minute", -times (role="listbox", plus data-waiting until a day
is chosen) and -time (role="option", aria-disabled while the list waits).
unstyled drops the design-system classes — the root’s and every part’s — and keeps every
data-*, role and aria-* attribute, so the behaviour survives a skin of your own.
className merges after the root class.
The signature detail is the segment. It is not a painted cell over a hidden input: it
is the platform’s own text selection, moved with setSelectionRange, so it spins with the
arrows, jumps on its own as digits land, and reads to a screen reader as the text it is.
Empty positions are drawn with an en dash, one per character; a field with nothing in it at
all shows your placeholder instead.
DatePickerProps is a union of three shapes, discriminated by mode and granularity.
It extends ComponentProps<"input"> minus value, defaultValue, onChange, size,
color, width, height, name, children, type, inputMode and autoComplete —
the first eight redefined below, the last three pinned by the component.
| Prop | Type | Default | Description |
|---|---|---|---|
mode |
"single" | "range" |
"single" |
Two ends, in one input. |
granularity |
"day" | "minute" |
"day" |
minute adds HH:mm and the list of times. Reserved with mode="range" — the pair throws. |
value |
string | null or DateRange |
— | Controlled. null clears. Ignored inside a Form with a name: the store wins. |
defaultValue |
the same | — | Seeds once, on mount. Outside a Form, ignored once value is given. |
onValueChange |
(value) => void |
— | Fires on every commit, including a retyped segment of a complete date. |
locale |
string |
the platform’s | Mask, names, first day of the week. |
format |
string |
the locale’s | TR35 subset. An unsupported token, or a pattern that does not fit the granularity, throws. |
weekStart |
1 … 7 |
the locale’s, else 1 |
1 = Monday, Temporal’s numbering. |
min / max |
string |
— | ISO bounds. |
isDateDisabled |
(iso: string) => boolean |
— | Per-cell veto. |
months |
number |
1 |
Months painted side by side in the panel. |
timeStep |
number |
30 |
Minutes between rows of the time list. Clamped to 1–60. |
open / defaultOpen / onOpenChange |
boolean / boolean / (open) => void |
false |
The panel’s controlled trio. |
labels |
{ previous, next, today, calendar, times? } |
— required | times is required under granularity="minute". |
variant |
"outline" | "filled" | "soft" | "ghost" |
"outline" |
As on Input. |
color |
WayraColor |
"primary" |
Edge, ring, today’s ring and the selection fill. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
The shared control ladder; forwarded to the calendar. |
shape |
"rounded" | "rectangle" |
"rounded" |
No pill. |
readOnly |
boolean |
false |
Focusable, copyable; the panel never opens. |
disabled |
boolean |
— | Freezes field, trigger and panel. A submitting Form sets it; a disabled one sets it whatever you pass. |
noRhf |
boolean |
false |
Opts this instance out of the surrounding Form. |
label, labelPlacement, helper, error, id, name, unstyled, className, ref |
The field block, identical to Input. ref reaches the visible input. |
Reserved names
Section titled “Reserved names”presets (a column of shortcuts), hourCycle="h12" and mode="range" with
granularity="minute" are reserved, not props. They are frozen now so none of them
arrives later as a breaking rename, and nothing dead ships in the meantime.
Custom properties
Section titled “Custom properties”This component has no public knob of its own. Its box belongs to the field family and
its panel to the overlay family, so it is themed by the tokens the README already
documents and Theming explains: --wayra-radius-field and --wayra-border
for the box, --wayra-size-field for the ladder every size step is a multiple of,
--wayra-color-base-100 and --wayra-color-base-content for the surfaces, and
--wayra-tone / --wayra-tone-content set on the element for a colour outside the
eight roles.
The month inside the panel is a Calendar, so
--wayra-calendar-cell is what sizes its days. Set it on the calendar — the panel’s,
or every one of them — and not on the field: the calendar declares its own default, and a
declared value beats an inherited one.
.wayra-date-picker-panel .wayra-calendar { --wayra-calendar-cell: 2.5rem;}Everything else this component reads is --_wayra-*: private, unregistered, and free to
change in a patch.
Accessibility
Section titled “Accessibility”- The APG date picker dialog. The input is a plain
textboxwitharia-haspopup="dialog"andaria-controls; the panel is a non-modalrole="dialog"named bylabels.calendar; the month is a real<table role="grid">ofrole="gridcell"cells with one roving tab stop. aria-expandedis on the trigger button, never on the input.textboxdoes not take it in ARIA 1.2, and an input carrying it fails axe’saria-allowed-attr. The trigger istabindex="-1", so the field is still a single tab stop.- Every cell announces itself in full — “jueves, 21 de agosto de 2026” from
Intl, plus its state fromaria-selected,aria-disabledandaria-current="date". Paging months is announced from the calendar’s ownrole="status", and so is the date you commit. - Focus choreography is the one thing JavaScript owns here: the trigger and
Alt+↓move focus onto the grid’s tab stop;Escapebrings it back to the input; a day pick atdaygranularity closes and returns it; atminuteit hands focus to the list of times. - The 24px floor. WCAG 2.5.8 is enforced twice — on the calendar’s cell and on the time
row — with
max(24px, …), so evensize="xs"stays clickable. - Forced colors: the chosen day and the chosen time turn
Highlight/HighlightText, today’s ring becomesCanvasText, the range band becomes a dottedCanvasTextoutline, a waiting time turnsGrayText, and the focus outline is repainted last so nothing covers it. - Reduced motion stops the hover fills and the title chevron, and turns the panel’s entrance into a 160ms fade in place. Nothing animates layout in the first place.
Do and don’t
Section titled “Do and don’t”Do
A mask. The order is the reader's country's, the segments turn with the arrows, and 1987 is four keystrokes away.
Don't
A free text field accepts 32/13/2026, and every user brings their own idea of the order. Parsing it afterwards is the bug you are choosing.