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
November 2026
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 26 | 27 | 28 | 29 | 30 | 31 | 1 |
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 1 | 2 | 3 | 4 | 5 | 6 |
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.
November 2026
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 26 | 27 | 28 | 29 | 30 | 31 | 1 |
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 1 | 2 | 3 | 4 | 5 | 6 |
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.
February 2031
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 1 | 2 |
January 2027
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 28 | 29 | 30 | 31 | 1 | 2 | 3 |
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Visible label. Also names the calendar popover. |
value | string | null | none | ISO date, such as "2026-11-13". |
defaultValue | string | null | none | Starting date when uncontrolled. |
onValueChange | (value: string | null) => void | none | Called with the ISO date once day, month and year are all set, and with null when a segment is cleared. |
min | string | none | Earliest valid day. Earlier days cannot be picked in the calendar; a typed date before it marks the field invalid. |
max | string | none | Latest valid day, with the same behaviour as min. |
name | string | none | Adds a hidden input carrying the ISO value, empty while incomplete. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
error | ReactNode | none | Error text under the control. Marks the segments aria-invalid. |
disabled | boolean | none | Disables the segments and the calendar button. |
locale | string | "en-GB" | Sets the segment order, separator and calendar names. |
weekStartsOn | 0 | 1 | 1 | First day of the week in the calendar. |
today | string | none | Today as an ISO date, for the calendar marker and empty-segment arrows. When absent, the device date is used after mount. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper, including ref. |
Anatomy
| root | .eq-date-field | |
|---|---|---|
| label | .eq-date-field-label | |
| control | .eq-date-field-control | The bordered group holding the segments and the button. |
| segment | .eq-date-field-segment | One spinbutton per day, month and year, in locale order. |
| separator | .eq-date-field-separator | The locale's separator, hidden from assistive tech. |
| trigger | .eq-date-field-trigger | A ghost Button that opens the calendar popover. |
| hint | .eq-date-field-hint | |
| error | .eq-date-field-error |
Keyboard
| 0-9 | Types into the focused segment and moves on once it is complete. |
|---|---|
| ArrowUpArrowDown | Steps the segment, wrapping day and month. An empty segment starts from today. |
| ArrowLeftArrowRight | Moves to the previous or next segment. |
| /-. | Moves to the next segment, as when typing a date in one go. |
| BackspaceDelete | Clears the segment. Backspace in an empty segment moves back. |
| EnterSpace | On the calendar button, opens the calendar with focus on the selected day. |
| Escape | Closes the calendar and returns focus to the button. |
| ArrowsPageUpPageDownHomeEnd | Inside 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;
}
}