Date Input
A date field with two ways in: type the date, or pick it off a calendar. Optional age bounds constrain both.
'use client'
import * as React from 'react'
import { DateInput } from '@/components/custom/date'
export default function DateDemo() {
const [birthday, setBirthday] = React.useState('')
const [startDate, setStartDate] = React.useState('')
return (
<div className="w-full max-w-sm space-y-4">
<DateInput
label="Date of birth"
required
value={birthday}
onChange={setBirthday}
minAge={18}
maxAge={100}
error={birthday === ''}
errorMessage="Required — must be 18 or older."
/>
<DateInput label="Start date" value={startDate} onChange={setStartDate} />
</div>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/date.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/date.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/date.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/date.jsonInstalls components/custom/date.tsx, form-field, and the shadcn calendar and popover primitives. The calendar brings react-day-picker with it.
Usage
'use client'
import { DateInput } from '@/components/custom/date'
export function BirthdayField({ field }) {
return (
<DateInput
label="Date of birth"
required
value={field.value}
onChange={field.onChange}
minAge={18}
maxAge={100}
/>
)
}The export is DateInput, not Date. A component named Date shadows the global constructor in every file that
imports it, so new Date() fails at runtime with nothing flagged at compile time.
Props
Extends React.ComponentProps<'input'> minus type, value and onChange.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Required. |
value | string | '' | ISO date, yyyy-mm-dd. |
onChange | (value: string) => void | — | Receives the ISO string, not the event. |
minAge | number | — | Youngest allowed age in years. Bounds the calendar's upper edge. |
maxAge | number | — | Oldest allowed age in years. Bounds the lower edge. |
min / max | string | — | ISO bounds. Take precedence over the age props. |
placeholder | string | 'yyyy-mm-dd' | Shown while empty. |
disabled | boolean | false | Blocks typing and the calendar. |
error | boolean | false | Switches the border and ring to the danger ramp. |
errorMessage | ReactNode | — | Shown only while error is true. |
Also exports toIsoDate(date) and parseIsoDate(value) for callers that need the same conversion elsewhere.
Behaviour notes
Why both affordances. A date two weeks out is a glance at a calendar; a date of birth is thirty years of paging. Typing covers the second case, and the calendar's month and year dropdowns make it survivable for anyone who would rather click.
Typing commits on blur and Enter, never per keystroke. A half-typed 199 is not a date, and clearing the field on the way to a real one makes the value flicker and trips validation that runs on change. A value that will not parse, or falls outside the bounds, reverts to the last good one rather than being silently accepted.
Impossible dates are rejected. new Date('2025-02-31') rolls forward to 3 March instead of failing, so the parsed date is formatted back and compared to the input — if they differ, the input named a day that does not exist.
The calendar opens where the answer is. On the selected date if there is one, otherwise at the nearest allowed edge. A date-of-birth field bounded at eighteen opens eighteen years ago, not today.
Bounds are recomputed each render, so a tab left open across midnight does not keep yesterday's limits. They apply in three places at once: the calendar greys out disallowed days, the year dropdown stops at the boundary, and typed input outside the range is refused.
onChange receives a string, so react-hook-form's field.onChange and a useState setter both pass straight through.
Empty means empty. A blank field renders blank rather than defaulting to a boundary date — showing a value the form state does not hold makes a required field look answered until submit disagrees.