Date input — About
The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.
Why a string and not a Date
Section titled “Why a string and not a Date”This is the decision everything else follows from.
// Somewhere west of Greenwich:new Date('2026-03-01').getDate() // 28 — parsed as UTC midnightnew Date(2026, 2, 1).getDate() // 1 — parsed as local midnightA calendar date is a year, a month and a day. It is not a point in time. The moment it becomes one it acquires a timezone it never had, and that single discrepancy is behind most “my date picker is a day off” reports in this space.
So no Date is ever constructed for a value. The component stores three numbers, formats them as
YYYY-MM-DD, and compares ranges as strings — which is correct because that format is fixed-width
and big-endian, so it sorts lexicographically in exactly the same order it sorts chronologically.
There is deliberately no toDate() helper. Adding one would put the timezone question back
inside the library, where it cannot be answered correctly. Construct a Date at your app’s boundary
and be explicit about the zone you mean.
No rollover
Section titled “No rollover”new Date(2026, 1, 31) // 3 March 2026A field built on that silently changes the month the user chose. This one refuses:
import { toISO } from '@rxova/react-date-input'
toISO({ year: 2026, month: 2, day: 31 }) // nulltoISO({ year: 2024, month: 2, day: 29 }) // '2024-02-29'toISO({ year: 2023, month: 2, day: 29 }) // nullWhen the user changes the month under an existing day, the day is clamped: 31 January then February gives the 28th. The other options are worse — rolling over undoes the month they just picked, and clearing the day throws away input they did not ask to lose.
Segment bounds narrow as the date fills in
Section titled “Segment bounds narrow as the date fills in”The day segment’s maximum is not fixed:
| Known so far | Day range |
|---|---|
| nothing | 1–31 |
| February, no year | 1–29 |
| February 2024 | 1–29 |
| February 2023 | 1–28 |
Narrowing any earlier would reject a value that is about to become valid — a user typing the day
first, then February, then a leap year. aria-valuemax tracks this, so a screen reader announces
the real range rather than a nominal one.
No partially-typed value escapes
Section titled “No partially-typed value escapes”Typing 1999 into a year passes through 1, 19 and 199 — and each of those is a complete date
once the other segments are filled. A component that emits unconditionally reports 0001-03-15 to
its parent, and a form that saves on change persists it.
So digits are provisional until the number is finished — when no further digit could keep it in
range, or the segment is full. The display and onPartsChange update immediately; onChange waits.
A number left half-typed is settled when focus moves away, so nothing the user actually left in the
field is withheld.
Locale handling
Section titled “Locale handling”Segment order, separators and month names all come from Intl.DateTimeFormat. Every engine ships
ICU already, so this costs nothing and is correct for locales we have never heard of.
import { segmentOrder } from '@rxova/react-date-input'
segmentOrder('en-US') // ['month', 'day', 'year']segmentOrder('en-GB') // ['day', 'month', 'year']segmentOrder('ja-JP') // ['year', 'month', 'day']Leading and trailing affixes are trimmed: ko-KR and hu-HU end with a ., ja-JP with 日 —
correct for display, but a dangling character after an editable field.
A malformed tag falls back to ISO order and warns. Intl throws RangeError on en_US, and
crashing a date field over an underscore is the worse outcome.
Two known limitations
Section titled “Two known limitations”- Digits are Latin numerals, even in a locale whose
Intloutput uses Arabic-Indic or Persian digits. Editing a numeral system the keyboard does not produce would be worse than the inconsistency. - Non-Gregorian locales take their order from their own calendar but edit Gregorian numbers.
fa-IRformats a Gregorian 3333 as a Persian year, so the order is right and the values are not in that calendar. A real Persian-calendar field is a different component.
Accessibility
Section titled “Accessibility”- A
role="group"of threerole="spinbutton"segments. A single control would leave a screen-reader user unable to tell which part they are editing; a spinbutton is exactly what a bounded number with arrow-key stepping is. - The month announces as its name —
aria-valuetext="March", localised. “3” is the value; “March” is the choice. - An empty segment announces its placeholder and carries no
aria-valuenow, rather than claiming a value of 0. - Separators are
aria-hidden; reading “slash” between two named spinbuttons adds nothing. - Arrow keys wrap the value but clamp the focus, so the field never becomes a trap you cannot arrow out of.
- Disabled segments stay in the accessibility tree but leave the tab order, matching native controls.
- A
ReactNodelabel is rendered off-screen and referenced witharia-labelledby, because a node cannot become anaria-labeland the group would otherwise lose its name.
axe (WCAG 2.1 A/AA) runs over the component in the browser suite and over the whole demo page in Chromium, Firefox and WebKit — including a right-to-left locale.
Why all three browsers
Section titled “Why all three browsers”The E2E suite runs Chromium, Firefox and WebKit, and for this component that is not a formality:
the entire segment layout comes from ICU data, and the three engines ship three different ICU
builds. “Does en-GB really put the day first here” is a question only a real run in each engine
can answer.
The locale tests assert relationships rather than exact separator characters — ICU changes those between versions, and pinning them would fail the suite on a Node upgrade for no real reason.
Diagnostics
Section titled “Diagnostics”onWarn receives { code, prop, received, message } whenever a prop is rejected or coerced.
| Code | Meaning |
|---|---|
value-unparseable | Not YYYY-MM-DD, or a well-formed string for a day that does not exist |
value-out-of-range | Complete and real, but outside min/max |
min-unparseable / max-unparseable | A bound that is not a real ISO date; it is ignored |
min-after-max | No date could satisfy both, so both bounds are dropped |
locale-invalid | Intl refused the tag; ISO segment order is used |
With no handler these go to console.warn. The entire path is stripped from production builds —
it sits behind a process.env.NODE_ENV !== 'production' branch, so there is no runtime cost and no
console noise in production. The E2E suite asserts this against a real production bundle.
Styling
Section titled “Styling”There is no stylesheet to import.
Custom properties
Section titled “Custom properties”Set them on [data-rx-date-root] or any ancestor:
[data-rx-date-root] { --rx-date-gap: 0.0625rem; --rx-date-segment-padding: 0 0.0625rem; --rx-date-segment-radius: 0.125rem; --rx-date-literal-opacity: 0.7; --rx-date-focus-ring: 2px solid Highlight; --rx-date-focus-ring-offset: 1px;}The focused segment paints a ring by default: a <span role="spinbutton"> gets none from the
browser, and shipping outline: none with nothing in its place leaves a keyboard user unable to
tell which of the three segments they are on. Restyle it through those two properties rather than
removing it.
Stable data-* hooks
Section titled “Stable data-* hooks”These are public API, covered by semver.
| Attribute | On | Meaning |
|---|---|---|
data-rx-date-root | wrapper | Always present |
data-complete | wrapper | Every segment filled and the date is real |
data-out-of-range | wrapper | Complete but outside min/max |
data-invalid / data-disabled / data-readonly | wrapper | Mirrors the props |
data-rx-date-segment | segment | day, month or year |
data-placeholder | segment | The segment is empty |
data-focused | segment | The segment has focus |
data-rx-date-value | hidden input | The ISO value a form posts |
Form libraries
Section titled “Form libraries”Every recipe below is transcribed from src/__tests__/form.browser.test.tsx, so it is code that
runs on every commit. What a form stores is YYYY-MM-DD — never a Date, and never the locale’s display order, so an en-GB field and a ja-JP field submit the same string.
Native forms
Section titled “Native forms”With a name, the component emits a hidden input carrying the ISO value:
<form action="/task" method="post"> <DateInput name="due" label="Due" locale="en-GB" /> <button type="submit">Save</button></form>React Hook Form
Section titled “React Hook Form”onChange emits the ISO string (or null), so field.onChange binds directly. It fires only once the date is complete, so the library never sees a half-typed value it might validate:
<Controller name="due" control={control} render={({ field }) => ( <DateInput label="Date" value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} /> )}/>Formik
Section titled “Formik”function Field() { const [field, , helpers] = useField<string | null>('due') return ( <DateInput label="Date" name="due" value={field.value} onChange={(value) => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} /> )}onBlur fires when focus leaves the whole field, not when it moves from the day segment to the month — so a field is not marked touched while the user is still typing the date.
React Final Form
Section titled “React Final Form”<Field name="due"> {({ input }) => ( <DateInput label="Date" name={input.name} value={input.value === '' ? null : String(input.value)} onChange={input.onChange} onBlur={input.onBlur} /> )}</Field>TanStack Form
Section titled “TanStack Form”<form.Field name="due"> {(field) => ( <DateInput label="Date" name="due" value={field.state.value} onChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> )}</form.Field>React Final Form starts a field as '', which is not an ISO date. The guard above is the binding’s
job: the component takes null for empty.
UI-library recipes
Section titled “UI-library recipes”Keep the locale-ordered spinbutton segments as one widget. The surrounding design-system field provides visual hierarchy without flattening the date into a fictional native text input.
shadcn/ui
'use client'
import { DateField } from '@/components/rxova/date-field'
export function ShadcnDate() {
return (
<DateField
label="Start date"
description="Enter a calendar date."
name="startDate"
locale="en-US"
/>
)
}
Radix Themes
'use client'
import { Box, Text } from '@radix-ui/themes'
import { DateInput } from '@rxova/react-date-input'
export function RadixDate() {
return (
<Box>
<Text as="div" size="2" weight="bold" mb="1">
Start date
</Text>
<DateInput label="Start date" name="startDate" locale="en-US" />
<Text as="div" size="1" color="gray" mt="1">
Enter a calendar date.
</Text>
</Box>
)
}
Material UI
'use client'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { DateInput } from '@rxova/react-date-input'
export function MuiDate() {
return (
<FormControl>
<FormLabel>Start date</FormLabel>
<DateInput label="Start date" name="startDate" locale="en-US" />
<FormHelperText>Enter a calendar date.</FormHelperText>
</FormControl>
)
}
Chakra UI
'use client'
import { Field } from '@chakra-ui/react'
import { DateInput } from '@rxova/react-date-input'
export function ChakraDate() {
return (
<Field.Root>
<Field.Label>Start date</Field.Label>
<DateInput label="Start date" name="startDate" locale="en-US" />
<Field.HelperText>Enter a calendar date.</Field.HelperText>
</Field.Root>
)
}
Mantine
'use client'
import { Input } from '@mantine/core'
import { DateInput } from '@rxova/react-date-input'
export function MantineDate() {
return (
<Input.Wrapper label="Start date" description="Enter a calendar date.">
<DateInput label="Start date" name="startDate" locale="en-US" />
</Input.Wrapper>
)
}
Ant Design
'use client'
import { Form } from 'antd'
import { DateInput } from '@rxova/react-date-input'
export function AntDate() {
return (
<Form.Item label="Start date" extra="Enter a calendar date.">
<DateInput label="Start date" name="startDate" locale="en-US" />
</Form.Item>
)
}