Rxova
Skip to content

Date input — About

The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.

This is the decision everything else follows from.

// Somewhere west of Greenwich:
new Date('2026-03-01').getDate() // 28 — parsed as UTC midnight
new Date(2026, 2, 1).getDate() // 1 — parsed as local midnight

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

new Date(2026, 1, 31) // 3 March 2026

A 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 }) // null
toISO({ year: 2024, month: 2, day: 29 }) // '2024-02-29'
toISO({ year: 2023, month: 2, day: 29 }) // null

When 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 farDay range
nothing1–31
February, no year1–29
February 20241–29
February 20231–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.

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.

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.

  1. Digits are Latin numerals, even in a locale whose Intl output uses Arabic-Indic or Persian digits. Editing a numeral system the keyboard does not produce would be worse than the inconsistency.
  2. Non-Gregorian locales take their order from their own calendar but edit Gregorian numbers. fa-IR formats 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.
  • A role="group" of three role="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 namearia-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 ReactNode label is rendered off-screen and referenced with aria-labelledby, because a node cannot become an aria-label and 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.

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.

onWarn receives { code, prop, received, message } whenever a prop is rejected or coerced.

CodeMeaning
value-unparseableNot YYYY-MM-DD, or a well-formed string for a day that does not exist
value-out-of-rangeComplete and real, but outside min/max
min-unparseable / max-unparseableA bound that is not a real ISO date; it is ignored
min-after-maxNo date could satisfy both, so both bounds are dropped
locale-invalidIntl 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.

There is no stylesheet to import.

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.

These are public API, covered by semver.

AttributeOnMeaning
data-rx-date-rootwrapperAlways present
data-completewrapperEvery segment filled and the date is real
data-out-of-rangewrapperComplete but outside min/max
data-invalid / data-disabled / data-readonlywrapperMirrors the props
data-rx-date-segmentsegmentday, month or year
data-placeholdersegmentThe segment is empty
data-focusedsegmentThe segment has focus
data-rx-date-valuehidden inputThe ISO value a form posts

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.

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>

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}
/>
)}
/>
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.

<Field name="due">
{({ input }) => (
<DateInput
label="Date"
name={input.name}
value={input.value === '' ? null : String(input.value)}
onChange={input.onChange}
onBlur={input.onBlur}
/>
)}
</Field>
<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.

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>
  )
}