Skip to content
UI Kit
Accent color

Date input

Type a date in the local format or pick it from a calendar with full keyboard support. Ranges too.

One of 7 components in Pickers and selections

2 variants

Variants

Field and calendar

The calendar unfolds from the field, its weeks come in one after another and the chosen day glides to its new place. Weekends are closed, Alt and Arrow down open it from the keyboard.

Range

Two months side by side from tablet up, one on phones. Days with bookings carry a dot.

Details

What Date input brings along, read from the registry and the source.

Variants
2
Version
1.0.1 · since 1.0.0
File
components/ui/date-input.tsx
Brings along
utilskiticoninputpopover
npm packages
None
Languages
English and German, 7 texts of its own
Studio
Adjust in code

Props

The DateInputProps interface from components/ui/date-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • valueoptional
    string | null
  • defaultValueoptional
    string | null
  • onValueChangeoptional
    (iso: string | null) => void
  • todayoptional
    string
  • minoptional
    string
  • maxoptional
    string
  • isDisabledoptional
    (iso: string) => boolean
  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • defaultOpenoptional
    boolean

    Open the calendar on first render, for demos

Built-in texts

Date input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
prevEnglish Previous monthGerman Vorheriger Monat
nextEnglish Next monthGerman Nächster Monat
openEnglish Choose dateGerman Datum wählen
todayEnglish TodayGerman Heute
invalidEnglish Enter a date like 12/24/2026.German Gib ein Datum wie 24.12.2026 ein.
clearEnglish Clear dateGerman Datum leeren
placeholderEnglish MM/DD/YYYYGerman TT.MM.JJJJ

Accessibility

Read from the source of the component.

  • Date input handles the keys Escape, Enter, Arrow down and Space itself.
  • Sets aria-current, aria-describedby, aria-invalid, aria-label, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles columnheader, dialog, grid, gridcell and row.
  • Announces changes through a live region without moving focus.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 491 lines
components/ui/date-input.tsx
'use client';

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CONTROL_HEIGHT, InlineButton, controlBox, useArrival, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';

/*
 * Dates are plain ISO strings (2026-10-19). No time zones, no Date objects
 * crossing the server and the browser, so the server render and the first
 * client render always agree.
 */

// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* Weeks of a newly shown month come in one after another */
const STYLE = `
@keyframes kitfx-date-input-row { from { opacity: 0; transform: translateY(-4px); } }
.kitfx-date-input-row { animation: kitfx-date-input-row 0.34s cubic-bezier(0.2,0.7,0.2,1) both; animation-delay: calc(var(--i, 0) * 28ms); }
@media (prefers-reduced-motion: reduce) { .kitfx-date-input-row { animation: none; } }
`;

const TEXTS = {
  en: { prev: 'Previous month', next: 'Next month', open: 'Choose date', today: 'Today', invalid: 'Enter a date like 12/24/2026.', clear: 'Clear date', placeholder: 'MM/DD/YYYY' },
  de: { prev: 'Vorheriger Monat', next: 'Nächster Monat', open: 'Datum wählen', today: 'Heute', invalid: 'Gib ein Datum wie 24.12.2026 ein.', clear: 'Datum leeren', placeholder: 'TT.MM.JJJJ' },
};
type Texts = (typeof TEXTS)['en'];

const pad = (n: number) => String(n).padStart(2, '0');
export const toISO = (y: number, m: number, d: number) => `${y}-${pad(m)}-${pad(d)}`;
const parts = (iso: string) => iso.split('-').map(Number) as [number, number, number];
const utc = (iso: string) => {
  const [y, m, d] = parts(iso);
  return new Date(Date.UTC(y, m - 1, d));
};
const fromUTC = (d: Date) => toISO(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
export const addDays = (iso: string, n: number) => {
  const d = utc(iso);
  d.setUTCDate(d.getUTCDate() + n);
  return fromUTC(d);
};
export const addMonths = (iso: string, n: number) => {
  const [y, m, d] = parts(iso);
  const first = new Date(Date.UTC(y, m - 1 + n, 1));
  const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
  return toISO(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(d, last));
};
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const weekStart = (l: Locale) => (l === 'de' ? 1 : 0);

/** Local date as ISO, for "today" on the client */
export const todayISO = () => {
  const d = new Date();
  return toISO(d.getFullYear(), d.getMonth() + 1, d.getDate());
};

/** 24.12.2026 or 12/24/2026 */
export function formatDate(iso: string, locale: Locale) {
  const [y, m, d] = parts(iso);
  return locale === 'de' ? `${pad(d)}.${pad(m)}.${y}` : `${pad(m)}/${pad(d)}/${y}`;
}

/** Reads what people type: 24.12.2026, 24.12.26, 2026-12-24, 12/24/2026 */
export function parseDate(text: string, locale: Locale): string | null {
  const s = text.trim();
  let y: number, m: number, d: number;
  let r: RegExpMatchArray | null;
  if ((r = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) [y, m, d] = [+r[1], +r[2], +r[3]];
  else if ((r = s.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/))) {
    const a = +r[1];
    const b = +r[2];
    y = +r[3] < 100 ? 2000 + +r[3] : +r[3];
    [d, m] = locale === 'de' || s.includes('.') ? [a, b] : [b, a];
  } else return null;
  const iso = toISO(y, m, d);
  return m >= 1 && m <= 12 && d >= 1 && fromUTC(utc(iso)) === iso ? iso : null;
}

/* ------------------------------------------------------------------ */
/* Calendar                                                            */
/* ------------------------------------------------------------------ */

export interface CalendarProps {
  mode?: 'single' | 'range';
  value?: string | null;
  onValueChange?: (iso: string) => void;
  range?: [string | null, string | null];
  onRangeChange?: (range: [string | null, string | null]) => void;
  /** The day marked as today. Pass it from the server for stable rendering. */
  today?: string;
  /** First month shown, ISO date in that month */
  month?: string;
  min?: string;
  max?: string;
  isDisabled?: (iso: string) => boolean;
  /** Small dots under days, e.g. days with bookings */
  marked?: string[];
  /** Two months side by side from tablet up */
  months?: 1 | 2;
  autoFocus?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/**
 * A month grid with full keyboard support (arrows, Home, End, Page Up, Page Down), single dates or ranges.
 *   <Calendar value="2026-10-19" onValueChange={setDate} />
 */
export function Calendar({ mode = 'single', value, onValueChange, range, onRangeChange, today: todayProp, month, min, max, isDisabled, marked, months = 1, autoFocus, labels, className }: CalendarProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const today = todayProp ?? '';
  const anchor = month ?? value ?? range?.[0] ?? (today || '2026-01-01');
  const [view, setView] = useState(() => toISO(parts(anchor)[0], parts(anchor)[1], 1));
  const [focus, setFocus] = useState(value ?? range?.[0] ?? (today || anchor));
  const [hover, setHover] = useState<string | null>(null);
  const gridRef = useRef<HTMLDivElement>(null);
  const moved = useRef(false);
  // Direction of the last month change, the new month slides in from that side
  const lastView = useRef(view);
  useEffect(() => {
    const dir = view > lastView.current ? 1 : view < lastView.current ? -1 : 0;
    lastView.current = view;
    const el = gridRef.current;
    if (!dir || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
    el.querySelectorAll<HTMLElement>('[data-month]').forEach((m) => m.animate([{ transform: `translateX(${dir * 14}px)`, opacity: 0.4 }, { transform: 'translateX(0)', opacity: 1 }], { duration: 340, easing: 'cubic-bezier(0.2,0.7,0.2,1)' }));
  }, [view]);

  // One mark slides to the chosen day (single dates only), the day buttons stay where they are
  const [mark, setMark] = useState<{ x: number; y: number; animate: boolean } | null>(null);
  const markView = useRef(view);
  useIsoLayoutEffect(() => {
    const root = gridRef.current;
    const btn = mode === 'single' && value ? root?.querySelector<HTMLElement>(`[data-day="${value}"]`) : null;
    if (!root || !btn) return setMark(null);
    let x = 0;
    let y = 0;
    for (let n: HTMLElement | null = btn; n && n !== root; n = n.offsetParent as HTMLElement | null) {
      x += n.offsetLeft;
      y += n.offsetTop;
    }
    const sameView = markView.current === view;
    markView.current = view;
    setMark((m) => ({ x, y, animate: Boolean(m) && sameView }));
  }, [value, view, mode]);

  const disabled = useCallback((iso: string) => (min && iso < min) || (max && iso > max) || isDisabled?.(iso) || false, [min, max, isDisabled]);

  useEffect(() => {
    if (!moved.current && !autoFocus) return;
    gridRef.current?.querySelector<HTMLButtonElement>(`[data-day="${focus}"]`)?.focus();
  }, [focus, view, autoFocus]);

  const weekdays = useMemo(() => {
    const f = new Intl.DateTimeFormat(intl(locale), { weekday: 'short', timeZone: 'UTC' });
    const long = new Intl.DateTimeFormat(intl(locale), { weekday: 'long', timeZone: 'UTC' });
    // 2026-01-04 is a Sunday
    return Array.from({ length: 7 }, (_, i) => {
      const d = utc(addDays('2026-01-04', i + weekStart(locale)));
      return { short: f.format(d).replace('.', '').slice(0, 2), long: long.format(d) };
    });
  }, [locale]);

  const choose = (iso: string) => {
    if (disabled(iso)) return;
    if (mode === 'range') {
      const [a, b] = range ?? [null, null];
      if (!a || b) onRangeChange?.([iso, null]);
      else onRangeChange?.(iso < a ? [iso, a] : [a, iso]);
    } else onValueChange?.(iso);
    setFocus(iso);
  };

  const go = (iso: string) => {
    moved.current = true;
    setFocus(iso);
    const v = toISO(parts(iso)[0], parts(iso)[1], 1);
    const last = addMonths(view, months - 1);
    if (iso < view || iso >= addMonths(last, 1)) setView(iso < view ? v : addMonths(v, -(months - 1)));
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const dow = (utc(focus).getUTCDay() - weekStart(locale) + 7) % 7;
    const map: Record<string, () => string> = {
      ArrowLeft: () => addDays(focus, -1),
      ArrowRight: () => addDays(focus, 1),
      ArrowUp: () => addDays(focus, -7),
      ArrowDown: () => addDays(focus, 7),
      Home: () => addDays(focus, -dow),
      End: () => addDays(focus, 6 - dow),
      PageUp: () => addMonths(focus, e.shiftKey ? -12 : -1),
      PageDown: () => addMonths(focus, e.shiftKey ? 12 : 1),
    };
    if (!map[e.key]) return;
    e.preventDefault();
    go(map[e.key]());
  };

  const [from, to] = range ?? [null, null];
  const end = to ?? (from && hover && hover > from ? hover : null);

  const renderMonth = (first: string, index: number) => {
    const [y, m] = parts(first);
    const title = new Intl.DateTimeFormat(intl(locale), { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(utc(first));
    const lead = (utc(first).getUTCDay() - weekStart(locale) + 7) % 7;
    const start = addDays(first, -lead);
    const days = Array.from({ length: 42 }, (_, i) => addDays(start, i));
    const weeks = Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)).filter((wk) => wk.some((d) => parts(d)[1] === m));
    const label = new Intl.DateTimeFormat(intl(locale), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
    return (
      <div key={first} data-month className={cn('min-w-0', index > 0 && 'max-sm:hidden')}>
        <div className="relative mb-3 flex h-9 items-center justify-center">
          {index === 0 && (
            <button type="button" aria-label={t.prev} onClick={() => setView(addMonths(view, -1))} className="absolute start-0 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2">
              <Icon name="chevron-left" size={17} />
            </button>
          )}
          <p className="text-sm font-semibold text-foreground" aria-live="polite">
            {title}
          </p>
          {index === 0 && months > 1 && (
            <button type="button" aria-label={t.next} onClick={() => setView(addMonths(view, 1))} className="absolute end-0 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:hidden after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2">
              <Icon name="chevron-right" size={17} />
            </button>
          )}
          {index === months - 1 && (
            <button type="button" aria-label={t.next} onClick={() => setView(addMonths(view, 1))} className="absolute end-0 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2">
              <Icon name="chevron-right" size={17} />
            </button>
          )}
        </div>
        <div role="grid" aria-label={title} onKeyDown={onKeyDown} className="w-full">
          <div role="row" className="mb-1 grid grid-cols-7">
            {weekdays.map((w) => (
              <span key={w.long} role="columnheader" aria-label={w.long} className="grid h-8 place-items-center text-xs font-medium text-subtle-foreground">
                {w.short}
              </span>
            ))}
          </div>
          {weeks.map((wk, wi) => (
            <div role="row" key={wi} className="kitfx-date-input-row grid grid-cols-7" style={{ '--i': wi } as React.CSSProperties}>
              {wk.map((d) => {
                const outside = parts(d)[1] !== m || parts(d)[0] !== y;
                if (outside) return <span key={d} role="gridcell" className="h-11 sm:h-10" />;
                const off = disabled(d);
                const selected = mode === 'single' ? d === value : d === from || d === to;
                const inRange = mode === 'range' && from && end && d > from && d < end;
                const rangeStart = mode === 'range' && from && end && d === from;
                const rangeEnd = mode === 'range' && from && end && d === end;
                return (
                  <span
                    key={d}
                    role="gridcell"
                    aria-selected={selected || Boolean(inRange) || undefined}
                    className={cn(
                      // Phones: a 44 px row, and the day button reaches over its whole cell
                      'relative flex h-11 items-center justify-center first:rounded-s-lg last:rounded-e-lg sm:h-10',
                      inRange && 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]',
                      rangeStart && 'bg-[linear-gradient(to_right,transparent_50%,color-mix(in_oklab,var(--primary)_10%,transparent)_50%)] rtl:bg-[linear-gradient(to_left,transparent_50%,color-mix(in_oklab,var(--primary)_10%,transparent)_50%)]',
                      rangeEnd && 'bg-[linear-gradient(to_left,transparent_50%,color-mix(in_oklab,var(--primary)_10%,transparent)_50%)]',
                    )}
                  >
                    <button
                      type="button"
                      data-day={d}
                      tabIndex={d === focus ? 0 : -1}
                      disabled={off}
                      aria-label={label.format(utc(d))}
                      aria-current={d === today ? 'date' : undefined}
                      aria-pressed={selected}
                      onClick={() => choose(d)}
                      onFocus={() => setFocus(d)}
                      onPointerEnter={() => mode === 'range' && setHover(d)}
                      className={cn(
                        'relative grid size-10 place-items-center rounded-lg text-sm tabular-nums text-foreground outline-none transition-[background-color,color,scale] duration-200 active:scale-90 active:duration-100 motion-reduce:active:scale-100',
                        'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
                        'max-sm:after:absolute max-sm:after:left-1/2 max-sm:after:top-1/2 max-sm:after:h-11 max-sm:after:w-11 max-sm:after:-translate-x-1/2 max-sm:after:-translate-y-1/2',
                        d === today && !selected && 'font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]',
                        selected && 'bg-primary font-medium text-primary-foreground shadow-[var(--shadow-sm)] hover:bg-primary',
                        // Once the sliding mark is measured it carries the surface
                        selected && mark && mode === 'single' && 'bg-transparent shadow-none hover:bg-transparent',
                        inRange && 'rounded-none hover:rounded-lg',
                        off && 'cursor-not-allowed text-[color-mix(in_oklab,var(--subtle-foreground)_70%,transparent)] hover:bg-transparent',
                      )}
                    >
                      {parts(d)[2]}
                      {marked?.includes(d) && <span aria-hidden className={cn('absolute bottom-1.5 size-1 rounded-full', selected ? 'bg-primary-foreground' : 'bg-primary')} />}
                      {d === today && !selected && <span aria-hidden className="absolute bottom-1.5 h-0.5 w-3 rounded-full bg-current opacity-60" />}
                    </button>
                  </span>
                );
              })}
            </div>
          ))}
        </div>
      </div>
    );
  };

  return (
    <div ref={gridRef} className={cn('relative w-full', months === 2 ? 'sm:grid sm:grid-cols-2 sm:gap-6' : 'max-w-[19.5rem] sm:max-w-[18.5rem]', className)} onPointerLeave={() => setHover(null)}>
      <style dangerouslySetInnerHTML={{ __html: STYLE }} />
      {mark && (
        <span
          aria-hidden
          className={cn(
            'pointer-events-none absolute left-0 top-0 size-10 rounded-lg bg-primary shadow-[var(--shadow-sm)]',
            mark.animate && 'transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.3,0.5,1)] motion-reduce:transition-none',
          )}
          style={{ translate: `${mark.x}px ${mark.y}px` }}
        />
      )}
      {Array.from({ length: months }, (_, i) => renderMonth(addMonths(view, i), i))}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Date input                                                          */
/* ------------------------------------------------------------------ */

export interface DateInputProps extends FieldOwnProps {
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (iso: string | null) => void;
  today?: string;
  min?: string;
  max?: string;
  isDisabled?: (iso: string) => boolean;
  size?: ControlSize;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Open the calendar on first render, for demos */
  defaultOpen?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

function DateBox({ value: valueProp, defaultValue, onValueChange, today: todayProp, min, max, isDisabled, size = 'md', name, id, disabled, required, invalid, defaultOpen, labels, className }: Omit<DateInputProps, keyof FieldOwnProps>) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const [inner, setInner] = useState<string | null>(defaultValue ?? null);
  const value = valueProp !== undefined ? valueProp : inner;
  const [text, setText] = useState(value ? formatDate(value, locale) : '');
  const [bad, setBad] = useState(false);
  const c = useControl({ id, invalid: invalid || bad || undefined, disabled, required });
  const [open, setOpen] = useState(Boolean(defaultOpen));
  const [today, setToday] = useState(todayProp ?? '');
  const mounted = useMounted();
  const boxRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const userOpened = useRef(false);
  useShake(boxRef, c.invalid);
  useEffect(() => {
    if (!todayProp) setToday(todayISO());
  }, [todayProp]);

  const commit = (iso: string | null) => {
    setInner(iso);
    onValueChange?.(iso);
    setText(iso ? formatDate(iso, locale) : '');
    setBad(false);
  };
  const close = useCallback(() => setOpen(false), []);
  const refs = useRef([boxRef, panelRef]).current;
  useDismiss(open, close, refs);

  const fromText = () => {
    if (!text.trim()) return commit(null);
    const iso = parseDate(text, locale);
    if (iso) commit(iso);
    else setBad(true);
  };

  return (
    <>
      <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
        <input
          id={c.id}
          value={text}
          inputMode="numeric"
          autoComplete="off"
          placeholder={t.placeholder}
          disabled={c.disabled}
          required={c.required}
          aria-invalid={c['aria-invalid']}
          aria-describedby={cn(c['aria-describedby'], bad && `${c.id}-bad`) || undefined}
          onChange={(e) => {
            setText(e.target.value);
            setBad(false);
          }}
          onBlur={fromText}
          onKeyDown={(e) => {
            if (e.key === 'Enter') fromText();
            if (e.key === 'ArrowDown' && e.altKey) {
              userOpened.current = true;
              setOpen(true);
            }
          }}
          className="h-full w-0 min-w-0 flex-1 bg-transparent ps-3.5 tabular-nums text-inherit outline-none placeholder:text-subtle-foreground"
        />
        <span className="flex shrink-0 items-center pe-1.5">
          <InlineButton
            label={t.open}
            icon="calendar"
            pressed={open}
            onClick={() => {
              userOpened.current = true;
              setOpen(!open);
            }}
          />
        </span>
      </div>
      {bad && <BadDate id={`${c.id}-bad`} text={t.invalid} />}
      {name && <input type="hidden" name={name} value={value ?? ''} />}
      {mounted && open && (
        <Floating anchor={boxRef} side="bottom" align="start" offset={6} ref={panelRef}>
          {(pos) => (
            <div
              role="dialog"
              aria-label={t.open}
              className={cn(
                // Phones: wide enough for seven 44 px day columns
                'p-3 max-sm:min-w-[min(21rem,calc(100vw-2rem))] transition-[opacity,translate,clip-path] duration-[280ms] ease-[var(--ease-kit)] [clip-path:inset(-40px_-40px_-60px_-40px)] starting:opacity-0 motion-reduce:transition-none',
                pos?.side === 'top' ? 'starting:translate-y-1.5 starting:[clip-path:inset(100%_-40px_-60px_-40px)]' : 'starting:-translate-y-1.5 starting:[clip-path:inset(-40px_-40px_100%_-40px)]',
                panelSurface,
              )}
              onKeyDown={(e) => e.key === 'Escape' && boxRef.current?.querySelector('input')?.focus()}
            >
              <Calendar
                value={value}
                today={today || undefined}
                min={min}
                max={max}
                isDisabled={isDisabled}
                autoFocus={userOpened.current}
                labels={labels}
                onValueChange={(iso) => {
                  commit(iso);
                  setOpen(false);
                  boxRef.current?.querySelector('input')?.focus();
                }}
              />
              {today && (
                <div className="mt-2 flex justify-between border-t border-border pt-2">
                  <button type="button" onClick={() => commit(null)} className="rounded-md px-2.5 py-1.5 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
                    {t.clear}
                  </button>
                  <button
                    type="button"
                    onClick={() => {
                      commit(today);
                      setOpen(false);
                    }}
                    className="rounded-md px-2.5 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent"
                  >
                    {t.today}
                  </button>
                </div>
              )}
            </div>
          )}
        </Floating>
      )}
    </>
  );
}

function BadDate({ id, text }: { id: string; text: string }) {
  const ref = useArrival<HTMLParagraphElement>();
  return (
    <p ref={ref} id={id} className="flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px]" />
      {text}
    </p>
  );
}

/**
 * A date field: type it in the local format or pick it from the calendar (Alt + Arrow down opens it).
 *   <DateInput label="Start date" defaultValue="2026-11-02" />
 */
export function DateInput({ label, hint, error, optional, labelAction, className, ...rest }: DateInputProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <DateBox {...rest} className={wrapped ? undefined : className} />);
}

Frequent questions

How do I install Date input?

With aiw add @ai-website/date-input. The command puts components/ui/date-input.tsx into your project, the code is yours from then on. It brings along icon, input and popover. It needs no further npm packages.

Which variants does Date input have?

2 variants, Field and calendar and Range.

Can I translate the texts of Date input?

Yes. Date input carries 7 texts of its own in English and German, such as “Previous month”, “Next month” and “Choose date”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Date input?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Date input with aiw_components and install it with aiw_add @ai-website/date-input.

Often searched

Jump to

Copied to the clipboard