Components · Forms
Date field
The native date input in the field style, or a from–to range that keeps the end after the start.
0.15% of the 2,062 sites in The UI Index have a date picker (3 sites).
Pick a weekday; we deploy at 09:00.
Pick a weekday; we deploy at 09:00.
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ date-field.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ date-field.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { dateField as c } from './date-field.variants';
export interface DateFieldProps {
id: string;
label: string;
defaultValue?: string;
min?: string;
max?: string;
hint?: string;
name?: string;
required?: boolean;
/** A from–to pair in one fieldset; label is then the legend, and defaultValue/min/max are for the "from" field. */
range?: { toValue?: string; fromLabel?: string; toLabel?: string };
className?: string;
}
/** A native date field in the field style, or a from–to pair whose "to" can't be before "from". */
export function DateField({ id, label, defaultValue, min, max, hint, name, required, range, className }: DateFieldProps) {
const [from, setFrom] = useState(defaultValue);
if (range)
return (
<fieldset data-date-range="" aria-describedby={hint ? `${id}-hint` : undefined} className={cn(c.range, className)}>
<legend className={c.legend}>{label}</legend>
<div className={c.pair}>
<div className={c.root}>
<label htmlFor={`${id}-from`} className={c.label}>{range.fromLabel ?? 'From'}</label>
<input type="date" id={`${id}-from`} name={name ? `${name}-from` : undefined} defaultValue={defaultValue} min={min} max={max} required={required} data-from="" className={c.input} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className={c.root}>
<label htmlFor={`${id}-to`} className={c.label}>{range.toLabel ?? 'To'}</label>
<input type="date" id={`${id}-to`} name={name ? `${name}-to` : undefined} defaultValue={range.toValue} min={from ?? min} max={max} required={required} data-to="" className={c.input} />
</div>
</div>
{hint && <p id={`${id}-hint`} className={c.hint}>{hint}</p>}
</fieldset>
);
return (
<div className={cn(c.root, className)}>
<label htmlFor={id} className={c.label}>{label}</label>
<input type="date" id={id} name={name} defaultValue={defaultValue} min={min} max={max} required={required} aria-describedby={hint ? `${id}-hint` : undefined} className={c.input} />
{hint && <p id={`${id}-hint`} className={c.hint}>{hint}</p>}
</div>
);
}import { fieldClasses } from '../input/input.variants';
/** A date field on the native date input: the platform's own calendar, keyboard entry per part, the user's
* locale, min and max. Restyled to the field (the picker button becomes a quiet calendar mark). A range is two
* fields in a fieldset (from, to), with the "to" field's min kept at the "from" date. */
export const dateField = {
root: 'grid gap-2',
label: 'text-sm font-medium',
// The ring is always there, transparent, and takes the ring colour on focus: focus sits in the day, month or year
// part, and the browser applies a focus style there a frame late (forced-colours modes show the outline too).
input: `${fieldClasses} h-9 w-full max-w-56 py-1 tabular-nums outline-2 outline-offset-0 outline-transparent [outline-style:solid] focus:border-ring focus:outline-ring [color-scheme:light] dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded-sm [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100`,
hint: 'text-sm text-muted-foreground',
range: 'm-0 grid min-w-0 gap-2 border-0 p-0',
legend: 'mb-2 p-0 text-sm font-medium',
pair: 'flex flex-wrap items-end gap-3',
};---
import { cn } from '@/lib/utils';
import { dateField as c } from './date-field.variants';
interface Props {
id: string;
label: string;
value?: string;
min?: string;
max?: string;
hint?: string;
name?: string;
required?: boolean;
/** A from–to pair in one fieldset; label is then the legend, and value/min/max are for the "from" field. */
range?: { toValue?: string; fromLabel?: string; toLabel?: string };
class?: string;
}
const { id, label, value, min, max, hint, name, required, range, class: className } = Astro.props;
---
{
range ? (
<fieldset data-date-range aria-describedby={hint ? `${id}-hint` : undefined} class={cn(c.range, className)}>
<legend class={c.legend}>{label}</legend>
<div class={c.pair}>
<div class={c.root}>
<label for={`${id}-from`} class={c.label}>{range.fromLabel ?? 'From'}</label>
<input type="date" id={`${id}-from`} name={name ? `${name}-from` : undefined} value={value} min={min} max={max} required={required} data-from class={c.input} />
</div>
<div class={c.root}>
<label for={`${id}-to`} class={c.label}>{range.toLabel ?? 'To'}</label>
<input type="date" id={`${id}-to`} name={name ? `${name}-to` : undefined} value={range.toValue} min={value ?? min} max={max} required={required} data-to class={c.input} />
</div>
</div>
{hint && <p id={`${id}-hint`} class={c.hint}>{hint}</p>}
</fieldset>
) : (
<div class={cn(c.root, className)}>
<label for={id} class={c.label}>{label}</label>
<input type="date" id={id} name={name} value={value} min={min} max={max} required={required} aria-describedby={hint ? `${id}-hint` : undefined} class={c.input} />
{hint && <p id={`${id}-hint`} class={c.hint}>{hint}</p>}
</div>
)
}
<script>
import './date-field.client';
</script>// Date range for Astro and plain HTML: in a [data-date-range], the "to" field's min follows the "from" value, and a
// "to" date before it moves up to it.
declare global {
interface Window {
__caseroomDate?: boolean;
}
}
if (typeof window !== 'undefined' && !window.__caseroomDate) {
window.__caseroomDate = true;
document.addEventListener('change', (e) => {
const from = e.target as HTMLInputElement;
if (!from.matches?.('[data-date-range] input[data-from]')) return;
const to = from.closest('[data-date-range]')!.querySelector<HTMLInputElement>('input[data-to]');
if (!to) return;
to.min = from.value;
if (to.value && from.value && to.value < from.value) to.value = from.value;
});
}
export {};import { fieldClasses } from '../input/input.variants';
/** A date field on the native date input: the platform's own calendar, keyboard entry per part, the user's
* locale, min and max. Restyled to the field (the picker button becomes a quiet calendar mark). A range is two
* fields in a fieldset (from, to), with the "to" field's min kept at the "from" date. */
export const dateField = {
root: 'grid gap-2',
label: 'text-sm font-medium',
// The ring is always there, transparent, and takes the ring colour on focus: focus sits in the day, month or year
// part, and the browser applies a focus style there a frame late (forced-colours modes show the outline too).
input: `${fieldClasses} h-9 w-full max-w-56 py-1 tabular-nums outline-2 outline-offset-0 outline-transparent [outline-style:solid] focus:border-ring focus:outline-ring [color-scheme:light] dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded-sm [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100`,
hint: 'text-sm text-muted-foreground',
range: 'm-0 grid min-w-0 gap-2 border-0 p-0',
legend: 'mb-2 p-0 text-sm font-medium',
pair: 'flex flex-wrap items-end gap-3',
};<div class="grid w-full max-w-md gap-8">
<div class="grid gap-2">
<label for="ex-launch" class="text-sm font-medium">Launch date</label>
<input type="date" id="ex-launch" value="2026-11-12" min="2026-11-05" aria-describedby="ex-launch-hint" class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 w-full max-w-56 py-1 tabular-nums outline-2 outline-offset-0 outline-transparent [outline-style:solid] focus:border-ring focus:outline-ring [color-scheme:light] dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded-sm [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100">
<p id="ex-launch-hint" class="text-sm text-muted-foreground">Pick a weekday; we deploy at 09:00.</p>
</div>
<fieldset data-date-range class="m-0 grid min-w-0 gap-2 border-0 p-0">
<legend class="mb-2 p-0 text-sm font-medium">Report period</legend>
<div class="flex flex-wrap items-end gap-3">
<div class="grid gap-2">
<label for="ex-report-from" class="text-sm font-medium">From</label>
<input type="date" id="ex-report-from" value="2026-11-01" data-from class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 w-full max-w-56 py-1 tabular-nums outline-2 outline-offset-0 outline-transparent [outline-style:solid] focus:border-ring focus:outline-ring [color-scheme:light] dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded-sm [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100">
</div>
<div class="grid gap-2">
<label for="ex-report-to" class="text-sm font-medium">To</label>
<input type="date" id="ex-report-to" value="2026-11-30" min="2026-11-01" data-to class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 w-full max-w-56 py-1 tabular-nums outline-2 outline-offset-0 outline-transparent [outline-style:solid] focus:border-ring focus:outline-ring [color-scheme:light] dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded-sm [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100">
</div>
</div>
</fieldset>
</div><script type="module" src="/caseroom/r/html/date-field.js"></script>