Skip to content
Caseroom

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.

Report period

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.

date-field.tsx
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>
  );
}
date-field.variants.ts
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',
};