Date and time

Date field

A labelled date entry typed by day, month and year segments, with a calendar in a popover.

Built on
Popover APIpopovertargetIntl.DateTimeFormat:has()
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.9 kB
Built from
ButtonCalendarPopover
Code
import { DateField } from "@/components/equip/date-field";

export default function Demo() {
  return <DateField label="Contract start" defaultValue="2026-11-13" />;
}

Examples

Range and hint

Days outside min and max cannot be picked in the calendar; a typed date outside them marks the field invalid.

Between 16 November and 18 December.

Code
import { DateField } from "@/components/equip/date-field";

export default function Example() {
  return (
    <DateField
      label="Ship by"
      defaultValue="2026-11-20"
      min="2026-11-16"
      max="2026-12-18"
      hint="Between 16 November and 18 December."
    />
  );
}

Error and disabled

Date of birth cannot be in the future.

Code
import { DateField } from "@/components/equip/date-field";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "start" }}>
      <DateField label="Date of birth" defaultValue="2031-02-14" error="Date of birth cannot be in the future." />
      <DateField label="Renewal date" defaultValue="2027-01-01" disabled />
    </div>
  );
}

In a form

The hidden input carries the ISO date, and stays empty until day, month and year are all filled.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { DateField } from "@/components/equip/date-field";

export default function Example() {
  const [sent, setSent] = useState<string | null>(null);
  return (
    <form
      style={{ display: "flex", flexWrap: "wrap", gap: 12, alignItems: "end" }}
      onSubmit={(event) => {
        event.preventDefault();
        setSent(String(new FormData(event.currentTarget).get("leave_start")) || "empty");
      }}
    >
      <DateField label="Leave starts" name="leave_start" locale="en-US" weekStartsOn={0} />
      <Button type="submit" variant="outline">
        Request leave
      </Button>
      {sent && <p style={{ margin: 0, width: "100%", color: "var(--eq-ink-2)" }}>leave_start = {sent}</p>}
    </form>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneVisible label. Also names the calendar popover.
valuestring | nullnoneISO date, such as "2026-11-13".
defaultValuestring | nullnoneStarting date when uncontrolled.
onValueChange(value: string | null) => voidnoneCalled with the ISO date once day, month and year are all set, and with null when a segment is cleared.
minstringnoneEarliest valid day. Earlier days cannot be picked in the calendar; a typed date before it marks the field invalid.
maxstringnoneLatest valid day, with the same behaviour as min.
namestringnoneAdds a hidden input carrying the ISO value, empty while incomplete.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
errorReactNodenoneError text under the control. Marks the segments aria-invalid.
disabledbooleannoneDisables the segments and the calendar button.
localestring"en-GB"Sets the segment order, separator and calendar names.
weekStartsOn0 | 11First day of the week in the calendar.
todaystringnoneToday as an ISO date, for the calendar marker and empty-segment arrows. When absent, the device date is used after mount.
...restComponentProps<"div">noneEvery native div attribute on the wrapper, including ref.

Anatomy

root.eq-date-field
label.eq-date-field-label
control.eq-date-field-controlThe bordered group holding the segments and the button.
segment.eq-date-field-segmentOne spinbutton per day, month and year, in locale order.
separator.eq-date-field-separatorThe locale's separator, hidden from assistive tech.
trigger.eq-date-field-triggerA ghost Button that opens the calendar popover.
hint.eq-date-field-hint
error.eq-date-field-error

Keyboard

0-9Types into the focused segment and moves on once it is complete.
ArrowUpArrowDownSteps the segment, wrapping day and month. An empty segment starts from today.
ArrowLeftArrowRightMoves to the previous or next segment.
/-.Moves to the next segment, as when typing a date in one go.
BackspaceDeleteClears the segment. Backspace in an empty segment moves back.
EnterSpaceOn the calendar button, opens the calendar with focus on the selected day.
EscapeCloses the calendar and returns focus to the button.
ArrowsPageUpPageDownHomeEndInside the calendar, as in Calendar.

Notes

  • Typing is by segments, never by parsing free text, so 03/04 cannot be read the wrong way round.
  • Each segment is a native input with role="spinbutton" and inputmode="numeric", so touch keyboards show digits.
  • The day is clamped when the month or year changes, so 31 then February becomes 28 or 29.
  • Picking a day closes the popover and puts focus back on the calendar button, whose name then includes the chosen date.
  • A typed date outside min or max sets aria-invalid and the danger border; pass error to say why.
  • The label points at the first segment, so clicking it starts typing.
  • Inherits Popover's support: the surface opens without JavaScript everywhere the Popover API exists, positioning uses CSS anchor positioning where available and a measured fallback elsewhere.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/date-field.tsx364 lines
"use client";

import {
  Fragment,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type ComponentProps,
  type KeyboardEvent,
  type ReactNode,
  type ToggleEvent,
} from "react";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { Popover } from "./popover";
import "./date-field.css";

type Kind = "day" | "month" | "year";
type Parts = Record<Kind, number | null>;

export type DateFieldProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  label: string;
  /** ISO date, "2026-11-13". null while the field is empty or incomplete. */
  value?: string | null;
  defaultValue?: string | null;
  /** Called with the ISO date once day, month and year are all set, and with null when one is cleared. */
  onValueChange?: (value: string | null) => void;
  min?: string;
  max?: string;
  /** Name of a hidden input that carries the ISO value in a form. */
  name?: string;
  hint?: ReactNode;
  error?: ReactNode;
  disabled?: boolean;
  locale?: string;
  weekStartsOn?: 0 | 1;
  /** Today as an ISO date, for the calendar's marker. When absent it is read from the device after mount. */
  today?: string;
};

const SEGMENTS: Record<Kind, { label: string; placeholder: string; length: number; min: number; max: number }> = {
  day: { label: "Day", placeholder: "dd", length: 2, min: 1, max: 31 },
  month: { label: "Month", placeholder: "mm", length: 2, min: 1, max: 12 },
  year: { label: "Year", placeholder: "yyyy", length: 4, min: 1, max: 9999 },
};

const pad = (n: number, length = 2) => String(n).padStart(length, "0");

function daysIn(year: number, month: number) {
  const date = new Date(0);
  date.setUTCFullYear(year, month, 0);
  return date.getUTCDate();
}

function toParts(iso: string | null | undefined): Parts {
  const match = iso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!match) return { day: null, month: null, year: null };
  return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
}

function toISO({ day, month, year }: Parts) {
  return day && month && year ? `${pad(year, 4)}-${pad(month)}-${pad(day)}` : null;
}

function localToday() {
  const now = new Date();
  return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}

/** Segment order and separator for the locale, read from a fixed date so the render is stable. */
function layoutFor(locale: string) {
  const parts = new Intl.DateTimeFormat(locale, {
    day: "2-digit",
    month: "2-digit",
    year: "numeric",
    timeZone: "UTC",
  }).formatToParts(new Date(Date.UTC(2026, 10, 13)));
  const order = parts.map((p) => p.type).filter((t): t is Kind => t === "day" || t === "month" || t === "year");
  const separator = parts.find((p) => p.type === "literal")?.value.trim() || "/";
  return { order: order.length === 3 ? order : (["day", "month", "year"] as Kind[]), separator };
}

export function DateField({
  label,
  value,
  defaultValue,
  onValueChange,
  min,
  max,
  name,
  hint,
  error,
  disabled,
  locale = "en-GB",
  weekStartsOn = 1,
  today,
  className,
  ...rest
}: DateFieldProps) {
  const id = useId();
  const [parts, setParts] = useState<Parts>(() => toParts(value ?? defaultValue));
  const [synced, setSynced] = useState(value);
  const iso = toISO(parts);
  if (value !== undefined && value !== synced) {
    setSynced(value);
    if (value !== iso) setParts(toParts(value));
  }
  const [typing, setTyping] = useState<{ kind: Kind; text: string } | null>(null);
  const [open, setOpen] = useState(false);
  const [calendarMonth, setCalendarMonth] = useState<string | undefined>();
  const [opened, setOpened] = useState(0);
  const inputs = useRef<Partial<Record<Kind, HTMLInputElement | null>>>({});
  const surface = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const { order, separator } = useMemo(() => layoutFor(locale), [locale]);
  const spoken = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }), [locale]);

  const outOfRange = iso !== null && ((min !== undefined && iso < min) || (max !== undefined && iso > max));
  const invalid = Boolean(error) || outOfRange;
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
  const popoverId = `${id}-calendar`;

  useEffect(() => {
    if (open) surface.current?.querySelector<HTMLElement>('.eq-calendar-day[tabindex="0"]')?.focus();
  }, [open]);

  function limit(kind: Kind, p: Parts) {
    const spec = SEGMENTS[kind];
    if (kind !== "day" || !p.month) return spec;
    return { ...spec, max: daysIn(p.year ?? 2000, p.month) };
  }

  function commit(next: Parts) {
    if (next.day && next.month) {
      const last = daysIn(next.year ?? 2000, next.month);
      if (next.day > last) next = { ...next, day: last };
    }
    setParts(next);
    const nextIso = toISO(next);
    if (nextIso !== iso) onValueChange?.(nextIso);
  }

  function focusAt(index: number) {
    const kind = order[index];
    if (kind) inputs.current[kind]?.focus();
  }

  function typeDigit(kind: Kind, digit: string) {
    const spec = limit(kind, parts);
    let text = (typing?.kind === kind ? typing.text : "") + digit;
    if (text.length > spec.length || Number(text) > spec.max) text = digit;
    const n = Number(text);
    setTyping({ kind, text });
    // A year is held until all four digits are in, so year 2 never clamps 29 February.
    if (n >= spec.min && (kind !== "year" || text.length === spec.length)) commit({ ...parts, [kind]: n });
    if (text.length >= spec.length || n * 10 > spec.max) focusAt(order.indexOf(kind) + 1);
  }

  function spin(kind: Kind, delta: number) {
    const spec = limit(kind, parts);
    const current = parts[kind];
    let n: number;
    if (current === null) {
      const [y, m, d] = (today ?? localToday()).split("-").map(Number);
      n = kind === "day" ? Math.min(d, spec.max) : kind === "month" ? m : y;
    } else if (kind === "year") {
      n = Math.min(spec.max, Math.max(spec.min, current + delta));
    } else {
      const span = spec.max - spec.min + 1;
      n = ((((current + delta - spec.min) % span) + span) % span) + spec.min;
    }
    setTyping(null);
    commit({ ...parts, [kind]: n });
  }

  function clear(kind: Kind) {
    setTyping(null);
    commit({ ...parts, [kind]: null });
  }

  function handleKeyDown(kind: Kind, event: KeyboardEvent<HTMLInputElement>) {
    const index = order.indexOf(kind);
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    if (/^\d$/.test(event.key)) {
      event.preventDefault();
      event.stopPropagation();
      typeDigit(kind, event.key);
      return;
    }
    switch (event.key) {
      case "ArrowUp":
        spin(kind, 1);
        break;
      case "ArrowDown":
        spin(kind, -1);
        break;
      case "ArrowLeft":
        focusAt(index - 1);
        break;
      case "ArrowRight":
        focusAt(index + 1);
        break;
      case "Backspace":
      case "Delete":
        if (parts[kind] === null && !typing && event.key === "Backspace") focusAt(index - 1);
        else clear(kind);
        break;
      default:
        if (event.key.length !== 1) return;
        event.stopPropagation();
        // Typing the separator moves on, as it would in a plain text date.
        if (/[\s./,-]/.test(event.key)) focusAt(index + 1);
    }
    event.preventDefault();
  }

  /** Touch keyboards send input events rather than key presses; read the digit that arrived. */
  function handleChange(kind: Kind, event: ChangeEvent<HTMLInputElement>) {
    const text = event.target.value;
    if (text.length < display(kind).length) {
      clear(kind);
      return;
    }
    const typed = text.charAt((event.target.selectionStart ?? text.length) - 1);
    if (/^\d$/.test(typed)) typeDigit(kind, typed);
  }

  function display(kind: Kind) {
    if (typing?.kind === kind) return typing.text;
    const n = parts[kind];
    return n === null ? "" : pad(n, SEGMENTS[kind].length);
  }

  function handleBeforeToggle(event: ToggleEvent<HTMLDivElement>) {
    if (event.newState !== "open") return;
    // A fresh calendar each time: focus starts on the chosen day and nothing replays.
    setOpened((n) => n + 1);
    setCalendarMonth((iso ?? today ?? localToday()).slice(0, 7));
  }

  function pick(next: string) {
    setTyping(null);
    commit(toParts(next));
    surface.current?.hidePopover();
    trigger.current?.focus();
  }

  return (
    <div
      {...rest}
      className={["eq-date-field", className].filter(Boolean).join(" ")}
      data-disabled={disabled || undefined}
    >
      <label className="eq-date-field-label" id={`${id}-label`} htmlFor={`${id}-${order[0]}`}>
        {label}
      </label>
      <div
        className="eq-date-field-control"
        role="group"
        aria-labelledby={`${id}-label`}
        aria-describedby={describedBy}
        data-invalid={invalid || undefined}
      >
        {order.map((kind, index) => {
          const spec = limit(kind, parts);
          const shown = display(kind);
          return (
            <Fragment key={kind}>
              {index > 0 && (
                <span className="eq-date-field-separator" aria-hidden="true">
                  {separator}
                </span>
              )}
              <input
                ref={(node) => {
                  inputs.current[kind] = node;
                }}
                id={`${id}-${kind}`}
                className="eq-date-field-segment"
                data-kind={kind}
                role="spinbutton"
                inputMode="numeric"
                autoComplete="off"
                spellCheck={false}
                aria-label={spec.label}
                aria-valuemin={spec.min}
                aria-valuemax={spec.max}
                aria-valuenow={parts[kind] ?? undefined}
                aria-valuetext={shown || "Empty"}
                aria-invalid={invalid || undefined}
                aria-describedby={describedBy}
                placeholder={spec.placeholder}
                value={shown}
                disabled={disabled}
                onFocus={() => setTyping(null)}
                onBlur={() => {
                  if (typing?.kind === "year" && kind === "year" && Number(typing.text) >= 1) {
                    commit({ ...parts, year: Number(typing.text) });
                  }
                  setTyping(null);
                }}
                onKeyDown={(event) => handleKeyDown(kind, event)}
                onChange={(event) => handleChange(kind, event)}
              />
            </Fragment>
          );
        })}
        <Button
          ref={trigger}
          className="eq-date-field-trigger"
          variant="ghost"
          size="s"
          popoverTarget={popoverId}
          aria-label={iso ? `Change date, ${spoken.format(new Date(`${iso}T00:00Z`))}` : "Choose date"}
          disabled={disabled}
          lead={
            <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
              <rect x="2.5" y="3.5" width="11" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
              <path d="M2.5 6.75h11M5.5 2v2.5M10.5 2v2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
            </svg>
          }
        />
      </div>
      {hint && (
        <p className="eq-date-field-hint" id={hintId}>
          {hint}
        </p>
      )}
      {error && (
        <p className="eq-date-field-error" id={errorId}>
          {error}
        </p>
      )}
      {name && <input type="hidden" name={name} value={iso ?? ""} />}
      <Popover
        ref={surface}
        id={popoverId}
        placement="bottom-end"
        role="dialog"
        aria-label={label}
        onBeforeToggle={handleBeforeToggle}
        onToggle={(event) => setOpen(event.newState === "open")}
      >
        <Calendar
          key={opened}
          value={iso}
          today={today}
          onValueChange={pick}
          month={calendarMonth}
          onMonthChange={setCalendarMonth}
          min={min}
          max={max}
          locale={locale}
          weekStartsOn={weekStartsOn}
        />
      </Popover>
    </div>
  );
}
components/equip/date-field.css121 lines
@layer equip {
  .eq-date-field {
    display: inline-grid;
    gap: var(--eq-space-1);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
  }

  .eq-date-field-label {
    width: fit-content;
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight-strong);
    color: var(--eq-ink);
  }

  .eq-date-field-control {
    display: flex;
    align-items: center;
    min-width: calc(var(--eq-h-m) * 5);
    height: var(--eq-h-m);
    padding-inline: var(--eq-space-3) 2px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    font-size: var(--eq-text-m);
    font-variant-numeric: tabular-nums;
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-date-field-control:has(.eq-date-field-segment:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-date-field-control[data-invalid] {
    border-color: var(--eq-danger);
  }

  .eq-date-field-segment {
    box-sizing: content-box;
    width: 2.4ch;
    height: 1.5em;
    margin: 0;
    padding: 0 1px;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: center;
    caret-color: transparent;
    outline: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease);
  }

  .eq-date-field-segment[data-kind="month"] {
    width: 2.9ch;
  }

  .eq-date-field-segment[data-kind="year"] {
    width: 4.4ch;
  }

  .eq-date-field-segment::placeholder {
    color: var(--eq-ink-3);
  }

  .eq-date-field-segment::selection {
    background: transparent;
  }

  /* The segment being typed into is the current one: solid, like a selection. */
  .eq-date-field-segment:focus {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-date-field-segment:focus::placeholder {
    color: color-mix(in srgb, var(--eq-solid-ink) 65%, transparent);
  }

  .eq-date-field-separator {
    color: var(--eq-ink-3);
    padding-inline: 1px;
  }

  .eq-date-field .eq-date-field-control .eq-date-field-trigger {
    width: var(--eq-h-s);
    padding: 0;
    margin-inline-start: auto;
    color: var(--eq-ink-2);
  }

  .eq-date-field-hint {
    margin: 0;
    font-size: var(--eq-text-s);
    color: var(--eq-ink-2);
  }

  .eq-date-field-error {
    margin: 0;
    font-size: var(--eq-text-s);
    color: var(--eq-danger);
  }

  .eq-date-field[data-disabled] .eq-date-field-control {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* The control is already dimmed; do not dim the button a second time. */
  .eq-date-field[data-disabled] .eq-date-field-control .eq-date-field-trigger {
    opacity: 1;
  }

  .eq-date-field[data-disabled] .eq-date-field-segment {
    cursor: not-allowed;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index