Zum Inhalt springen
UI Kit
Accent

Datumsfeld

Datum im Format der Sprache tippen oder im Kalender mit voller Tastatur wählen. Auch als Zeitraum.

Eine von 7 Components in Picker und Auswahlfelder

2 Variants

Variants

Feld und Kalender

Der Kalender klappt aus dem Feld auf, die Wochen kommen nacheinander und der gewählte Tag gleitet an seinen neuen Platz. Wochenenden sind gesperrt, Alt und Pfeil nach unten öffnen ihn per Tastatur.

Zeitraum

Ab Tablet zwei Monate nebeneinander, auf dem Telefon einer. Tage mit Buchungen tragen einen Punkt.

Details

Was Datumsfeld mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
2
Version
1.0.1 · seit 1.0.0
Datei
components/ui/date-input.tsx
Bringt mit
utilskiticoninputpopover
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle DateInputProps aus components/ui/date-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • 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

Eingebaute Texte

Diese Texte schreibt Datumsfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 7 Texte anzeigen
prevEnglisch Previous monthDeutsch Vorheriger Monat
nextEnglisch Next monthDeutsch Nächster Monat
openEnglisch Choose dateDeutsch Datum wählen
todayEnglisch TodayDeutsch Heute
invalidEnglisch Enter a date like 12/24/2026.Deutsch Gib ein Datum wie 24.12.2026 ein.
clearEnglisch Clear dateDeutsch Datum leeren
placeholderEnglisch MM/DD/YYYYDeutsch TT.MM.JJJJ

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Datumsfeld reagiert selbst auf die Tasten Escape, Enter, Pfeil runter und Leertaste.
  • Setzt aria-current, aria-describedby, aria-invalid, aria-label, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen columnheader, dialog, grid, gridcell und row.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 491 Zeilen
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} />);
}

Häufige Fragen

Wie installiere ich Datumsfeld?

Mit aiw add @ai-website/date-input. Der Befehl legt die Datei components/ui/date-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input und popover. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Datumsfeld?

2 Varianten, Feld und Kalender und Zeitraum.

Kann ich die Texte von Datumsfeld übersetzen?

Ja. Datumsfeld bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Vorheriger Monat“, „Nächster Monat“ und „Datum wählen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Datumsfeld einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Datumsfeld mit aiw_components und installieren sie mit aiw_add @ai-website/date-input.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert