Skip to content

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',
}}
/>

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.

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.

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#formatToParts gives the field order and the separators, so es-EC writes 21/08/2026, en-US writes 08/21/2026 and de-DE writes 21.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.

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" … />

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.

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.

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

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.

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.

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 later
eve.add({ hours: 24 }); // 2026-10-25T19:00 — 24 hours exactly, an hour earlier on the clock

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

Las dos fechas viven en un solo campo.

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". shouldFocusError calls focus() 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. onValueChange still reports null, 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.

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.

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.

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.

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.

  • The APG date picker dialog. The input is a plain textbox with aria-haspopup="dialog" and aria-controls; the panel is a non-modal role="dialog" named by labels.calendar; the month is a real <table role="grid"> of role="gridcell" cells with one roving tab stop.
  • aria-expanded is on the trigger button, never on the input. textbox does not take it in ARIA 1.2, and an input carrying it fails axe’s aria-allowed-attr. The trigger is tabindex="-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 from aria-selected, aria-disabled and aria-current="date". Paging months is announced from the calendar’s own role="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; Escape brings it back to the input; a day pick at day granularity closes and returns it; at minute it 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 even size="xs" stays clickable.
  • Forced colors: the chosen day and the chosen time turn Highlight / HighlightText, today’s ring becomes CanvasText, the range band becomes a dotted CanvasText outline, a waiting time turns GrayText, 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

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.