Date and time

Date picker

A date typed as free text or picked from a calendar in a popover, with limits and a clear button.

Built on
Popover APIpopovertargetIntl.DateTimeFormatsetCustomValidity:user-invalid
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.5 kB
Built from
ButtonCalendarPopover

Type it or pick it.

Code
import { DatePicker } from "@/components/equip/date-picker";

export default function Demo() {
  return (
    <div style={{ width: 216 }}>
      <DatePicker label="Ship by" defaultValue="2026-11-20" min="2026-11-13" today="2026-11-13" hint="Type it or pick it." />
    </div>
  );
}

Examples

Typed entry

Type 20/12/26, 2026-12-20 or 20 Dec 2026; numbers are read in the locale's order, and leaving the field rewrites the date with the month as a word.

value = 2026-12-20

Code
"use client";

import { useState } from "react";
import { DatePicker } from "@/components/equip/date-picker";

export default function Example() {
  const [value, setValue] = useState<string | null>("2026-12-20");
  return (
    <div style={{ display: "grid", gap: 8, width: 240 }}>
      <DatePicker label="Invoice date" value={value} onValueChange={setValue} today="2026-11-13" />
      <p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>value = {value ?? "null"}</p>
    </div>
  );
}

Limits, locale and states

Days outside min and max cannot be picked; a date outside them, typed or given, is marked invalid with a message.

Between 16 November and 18 December.

Choose a date on or before 18 Dec 2026.

Code
import { DatePicker } from "@/components/equip/date-picker";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 16, width: 240 }}>
      <DatePicker
        label="Delivery day"
        min="2026-11-16"
        max="2026-12-18"
        defaultValue="2026-12-24"
        today="2026-11-13"
        hint="Between 16 November and 18 December."
      />
      <DatePicker label="Start date" locale="en-US" weekStartsOn={0} defaultValue="2026-11-13" today="2026-11-13" />
      <DatePicker label="Contract end" defaultValue="2027-06-30" today="2026-11-13" disabled />
    </div>
  );
}

In a form

The hidden input carries the ISO date; required and the range checks run through the browser's own validation.

Code
"use client";

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

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("check_in")));
      }}
    >
      <DatePicker label="Check-in" name="check_in" required min="2026-11-13" today="2026-11-13" style={{ width: 220 }} />
      <Button type="submit" variant="outline">
        Check availability
      </Button>
      {sent && <p style={{ margin: 0, width: "100%", color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>check_in = {sent}</p>}
    </form>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the text input.
valuestring | nullnoneISO date, such as "2026-11-13".
defaultValuestring | nullnoneStarting date when uncontrolled.
onValueChange(value: string | null) => voidnoneCalled with the ISO date as soon as the typed text reads as a date, on a pick and on clear; null while it does not.
minstringnoneEarliest valid day. Earlier days cannot be picked; a typed one fails validation.
maxstringnoneLatest valid day, with the same behaviour as min.
namestringnoneAdds a hidden input carrying the ISO value, empty while there is no valid date.
requiredbooleannoneNative required on the text input.
disabledbooleannoneDisables the input and both buttons.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
errorReactNodenoneError text. Replaces the part's own message for unreadable or out-of-range dates.
localestring"en-GB"Order typed numbers are read in, the shown format and the calendar's names.
weekStartsOn0 | 11First day of the week in the calendar.
todaystringnoneToday as an ISO date, for the calendar marker. When absent, the device date is used after mount.
clearablebooleantrueShows a clear button while the field has text.
placeholderstringnoneDefaults to an example date in the locale's order, such as 13/11/2026.
...restComponentProps<"div">noneEvery native div attribute on the wrapper, including ref.

Anatomy

root.eq-date-picker
label.eq-date-picker-label
control.eq-date-picker-controlThe bordered box; data-invalid while a message shows.
input.eq-date-picker-inputThe text input. Carries required and the custom validity.
clear.eq-date-picker-clearShown while there is text.
trigger.eq-date-picker-triggerA ghost Button that opens the calendar popover.
hint.eq-date-picker-hint
error.eq-date-picker-error

Keyboard

0-9/-.Types the date as text.
EnterRewrites a readable date in the shown format, then submits the form as natively.
Alt+ArrowDownOpens the calendar from the text input.
EscapeIn the text input, clears it. In the calendar, closes it.
EnterSpaceOn the calendar button, opens the calendar with focus on the selected day.
ArrowsPageUpPageDownHomeEndInside the calendar, as in Calendar.

Notes

  • Unlike Date field, which types day, month and year in separate segments, this is one text input that reads what is typed: pick the one that suits your audience.
  • Numbers are read in the locale's order, so 03/04/2027 is 3 April in en-GB and March 4 in en-US; ISO and dates with the month as a word are read in any locale.
  • Leaving the field rewrites a readable date with the month as a word, such as 3 Apr 2027, so the reading is never in doubt.
  • Impossible dates such as 31/04/2027 are refused rather than rolled over into May.
  • An unreadable or out-of-range date is set with setCustomValidity on the input, so form validation and Form's summary see it; a date out of range is flagged at once, unreadable text once the field has been left.
  • Picking a day closes the popover and returns focus to the text input.
  • 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-picker.tsx341 lines
"use client";

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

export type DatePickerProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  label: ReactNode;
  /** ISO date, "2026-11-13". null while empty or while the typed text is not a date. */
  value?: string | null;
  defaultValue?: string | null;
  /** Called with the ISO date whenever the typed text becomes a date, on a pick and on clear. */
  onValueChange?: (value: string | null) => void;
  /** Earliest valid day, ISO. */
  min?: string;
  /** Latest valid day, ISO. */
  max?: string;
  /** Name of a hidden input that carries the ISO value in a form. */
  name?: string;
  required?: boolean;
  disabled?: boolean;
  hint?: ReactNode;
  /** Error text. Replaces the part's own message for unreadable or out-of-range dates. */
  error?: ReactNode;
  /** Sets the order typed numbers are read in, the shown format and the calendar's names. */
  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;
  /** Shows a button that empties the field while it has a date. */
  clearable?: boolean;
  placeholder?: string;
};

type Kind = "day" | "month" | "year";

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

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

/** Builds the ISO date, or null when the day does not exist (31 April, 29 February 2027). */
function toISO(year: number, month: number, day: number): string | null {
  if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1) return null;
  const date = new Date(0);
  date.setUTCFullYear(year, month - 1, day);
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
  return `${pad(year, 4)}-${pad(month)}-${pad(day)}`;
}

function isISO(text: string | null | undefined): text is string {
  const m = text?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  return !!m && toISO(Number(m[1]), Number(m[2]), Number(m[3])) !== null;
}

/** Order of day, month and year for the locale, and its numeric separator, read from a fixed date. */
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 };
}

/** Lower-case month names, long and short, so "13 Nov 2026" and "November 13, 2026" both read. */
function monthNamesFor(locale: string) {
  return (["long", "short"] as const).flatMap((month) => {
    const format = new Intl.DateTimeFormat(locale, { month, timeZone: "UTC" });
    return Array.from({ length: 12 }, (_, i) => ({
      name: format.format(new Date(Date.UTC(2026, i, 1))).toLowerCase().replace(/\.$/, ""),
      month: i + 1,
    }));
  });
}

/**
 * Reads typed text as a date. Accepts ISO, numbers in the locale's order with any of / . - or
 * space between them, and a month written as a word. Two-digit years are 2000 to 2099.
 */
function parse(text: string, order: Kind[], months: { name: string; month: number }[]): string | null {
  const t = text.trim().toLowerCase();
  if (!t) return null;
  const iso = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
  if (iso) return toISO(Number(iso[1]), Number(iso[2]), Number(iso[3]));

  const tokens = t.split(/[\s/.,-]+/).filter(Boolean);
  if (tokens.length !== 3) return null;
  const word = tokens.findIndex((token) => /\p{L}/u.test(token));
  const year = (s: string) => (/^\d{4}$/.test(s) ? Number(s) : /^\d{2}$/.test(s) ? 2000 + Number(s) : NaN);

  if (word !== -1) {
    const found = months.find((m) => m.name === tokens[word]) ?? months.find((m) => tokens[word].length >= 3 && m.name.startsWith(tokens[word]));
    if (!found) return null;
    const numbers = tokens.filter((_, i) => i !== word);
    if (!numbers.every((n) => /^\d+$/.test(n))) return null;
    // With the month as a word, the four-digit number is the year and the other is the day.
    const [a, b] = numbers;
    const y = a.length === 4 ? a : b;
    const d = a.length === 4 ? b : a;
    if (d.length > 2) return null;
    return toISO(year(y), found.month, Number(d));
  }

  if (!tokens.every((n) => /^\d+$/.test(n))) return null;
  const parts: Record<Kind, number> = { day: NaN, month: NaN, year: NaN };
  order.forEach((kind, i) => {
    const token = tokens[i];
    parts[kind] = kind === "year" ? year(token) : token.length <= 2 ? Number(token) : NaN;
  });
  if (Object.values(parts).some(Number.isNaN)) return null;
  return toISO(parts.year, parts.month, parts.day);
}

export function DatePicker({
  label,
  value,
  defaultValue,
  onValueChange,
  min,
  max,
  name,
  required,
  disabled,
  hint,
  error,
  locale = "en-GB",
  weekStartsOn = 1,
  today,
  clearable = true,
  placeholder,
  className,
  ...rest
}: DatePickerProps) {
  const id = useId();
  const inputId = `${id}-input`;
  const popoverId = `${id}-calendar`;
  const { order, separator } = useMemo(() => layoutFor(locale), [locale]);
  const months = useMemo(() => monthNamesFor(locale), [locale]);
  const shown = useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }), [locale]);
  const spoken = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }), [locale]);
  const format = (iso: string | null | undefined) => (isISO(iso) ? shown.format(new Date(`${iso}T00:00Z`)) : "");
  const example = order.map((kind) => (kind === "day" ? "13" : kind === "month" ? "11" : "2026")).join(separator);

  const start = isISO(value ?? defaultValue) ? (value ?? defaultValue ?? null) : null;
  const [iso, setIso] = useState<string | null>(start);
  const [text, setText] = useState(() => format(start));
  const [synced, setSynced] = useState(value);
  const [touched, setTouched] = useState(false);
  const [open, setOpen] = useState(false);
  const [month, setMonth] = useState<string | undefined>();
  const [opened, setOpened] = useState(0);

  // A controlled value that changes from outside rewrites the text.
  if (value !== undefined && value !== synced) {
    setSynced(value);
    if (value !== iso) {
      setIso(isISO(value) ? value : null);
      setText(format(value));
    }
  }

  const inputRef = useRef<HTMLInputElement>(null);
  const surface = useRef<HTMLDivElement>(null);

  const unreadable = text.trim() !== "" && iso === null;
  const early = iso !== null && min !== undefined && iso < min;
  const late = iso !== null && max !== undefined && iso > max;
  const problem = unreadable
    ? `Enter a date like ${example} or ${format("2026-11-13")}.`
    : early
      ? `Choose a date on or after ${format(min)}.`
      : late
        ? `Choose a date on or before ${format(max)}.`
        : "";
  // An unreadable date waits until the field has been left, so typing is not interrupted. A date
  // out of range is a real date that cannot be used, so it is flagged at once.
  const message = error || (unreadable ? touched && problem : problem) || null;
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = message ? `${id}-error` : undefined;

  // The input carries the problem for constraint validation, so a form's checks and :user-invalid see it.
  useLayoutEffect(() => {
    inputRef.current?.setCustomValidity(problem);
  }, [problem]);

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

  // A form reset puts the field back to where it started.
  useEffect(() => {
    const form = inputRef.current?.form;
    if (!form) return;
    function reset() {
      setTouched(false);
      commit(start);
      setText(format(start));
    }
    form.addEventListener("reset", reset);
    return () => form.removeEventListener("reset", reset);
  });

  function commit(next: string | null) {
    if (next === iso) return;
    setIso(next);
    onValueChange?.(next);
  }

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    const next = event.target.value;
    setText(next);
    commit(parse(next, order, months));
  }

  /** Leaving the field, or Enter, turns a readable date into the one unambiguous format. */
  function settle() {
    setTouched(true);
    if (iso) setText(format(iso));
  }

  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter") settle();
    else if (event.key === "ArrowDown" && event.altKey) {
      event.preventDefault();
      surface.current?.showPopover();
    } else if (event.key === "Escape" && text !== "" && !open) {
      event.preventDefault();
      event.stopPropagation();
      clear();
    }
  }

  function clear() {
    setText("");
    commit(null);
    inputRef.current?.focus();
  }

  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);
    setMonth((iso && (!min || iso >= min) ? iso : (today ?? localToday())).slice(0, 7));
  }

  function pick(next: string) {
    commit(next);
    setText(format(next));
    setTouched(true);
    surface.current?.hidePopover();
    inputRef.current?.focus();
  }

  return (
    <div {...rest} className={["eq-date-picker", className].filter(Boolean).join(" ")} data-disabled={disabled || undefined}>
      <label className="eq-date-picker-label" htmlFor={inputId}>
        {label}
      </label>
      <div className="eq-date-picker-control" data-invalid={Boolean(message) || undefined}>
        <input
          ref={inputRef}
          id={inputId}
          className="eq-date-picker-input"
          type="text"
          autoComplete="off"
          spellCheck={false}
          placeholder={placeholder ?? example}
          value={text}
          required={required}
          disabled={disabled}
          aria-invalid={Boolean(message) || undefined}
          aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
          onChange={handleChange}
          onBlur={settle}
          onKeyDown={handleKeyDown}
        />
        {clearable && text !== "" && !disabled && (
          <button type="button" className="eq-date-picker-clear" aria-label="Clear date" onClick={clear}>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
              <path d="m5 5 6 6m0-6-6 6" />
            </svg>
          </button>
        )}
        <Button
          className="eq-date-picker-trigger"
          variant="ghost"
          size="s"
          popoverTarget={popoverId}
          aria-label={iso ? `Change date, ${spoken.format(new Date(`${iso}T00:00Z`))}` : "Choose date"}
          aria-expanded={open}
          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-picker-hint" id={hintId}>
          {hint}
        </p>
      )}
      {message && (
        <p className="eq-date-picker-error" id={errorId}>
          {message}
        </p>
      )}
      {name && <input type="hidden" name={name} value={iso ?? ""} />}
      <Popover
        ref={surface}
        id={popoverId}
        placement="bottom-end"
        role="dialog"
        aria-label={typeof label === "string" ? label : "Choose date"}
        onBeforeToggle={handleBeforeToggle}
        onToggle={(event) => setOpen(event.newState === "open")}
      >
        <Calendar
          key={opened}
          value={iso}
          today={today}
          onValueChange={pick}
          month={month}
          onMonthChange={setMonth}
          min={min}
          max={max}
          locale={locale}
          weekStartsOn={weekStartsOn}
        />
      </Popover>
    </div>
  );
}
components/equip/date-picker.css144 lines
@layer equip {
  .eq-date-picker {
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
  }

  .eq-date-picker-label {
    justify-self: start;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  /* The bordered box: the text input, then the clear and calendar buttons inside it. */
  .eq-date-picker-control {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: 2px;
    min-width: calc(var(--eq-h-m) * 5);
    height: var(--eq-h-m);
    padding-inline-end: 2px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    /* :where keeps this below the invalid rules, so a red border stays red on hover. */
    .eq-date-picker-control:hover:where(:not(:has(.eq-date-picker-input:disabled))) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

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

  .eq-date-picker-control[data-invalid],
  .eq-date-picker-control:has(.eq-date-picker-input:user-invalid) {
    border-color: var(--eq-danger);
  }

  .eq-date-picker-input {
    box-sizing: border-box;
    flex: 1;
    min-width: 0;
    height: 100%;
    margin: 0;
    padding: 0 var(--eq-space-1) 0 var(--eq-space-3);
    border: 0;
    border-radius: inherit;
    background: transparent;
    color: inherit;
    font: inherit;
    font-variant-numeric: tabular-nums;
    outline: none;
  }

  .eq-date-picker-input::placeholder {
    color: var(--eq-ink-3);
    opacity: 1;
  }

  .eq-date-picker-clear {
    position: relative;
    display: grid;
    flex: none;
    place-items: center;
    width: 24px;
    height: 24px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-3);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-date-picker-clear > svg {
    width: 14px;
    height: 14px;
  }

  @media (hover: hover) {
    .eq-date-picker-clear:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-date-picker-clear:active {
    scale: var(--eq-press);
  }

  .eq-date-picker-clear:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 0;
  }

  .eq-date-picker .eq-date-picker-control .eq-date-picker-trigger {
    flex: none;
    width: var(--eq-h-s);
    padding: 0;
    color: var(--eq-ink-2);
  }

  .eq-date-picker-hint,
  .eq-date-picker-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-date-picker-hint {
    color: var(--eq-ink-2);
  }

  .eq-date-picker-error {
    color: var(--eq-danger);
  }

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

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

  .eq-date-picker[data-disabled] .eq-date-picker-input {
    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