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
Type it or pick it.
| 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 { 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.
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 30 | 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 | 1 | 2 | 3 |
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.
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 30 | 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 | 1 | 2 | 3 |
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| 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 |
| M | T | W | T | F | S | S |
|---|---|---|---|---|---|---|
| 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 |
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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the text input. |
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 as soon as the typed text reads as a date, on a pick and on clear; null while it does not. |
min | string | none | Earliest valid day. Earlier days cannot be picked; a typed one fails validation. |
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 there is no valid date. |
required | boolean | none | Native required on the text input. |
disabled | boolean | none | Disables the input and both buttons. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
error | ReactNode | none | Error text. Replaces the part's own message for unreadable or out-of-range dates. |
locale | string | "en-GB" | Order typed numbers are read in, the shown format and the calendar's 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. When absent, the device date is used after mount. |
clearable | boolean | true | Shows a clear button while the field has text. |
placeholder | string | none | Defaults to an example date in the locale's order, such as 13/11/2026. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper, including ref. |
Anatomy
| root | .eq-date-picker | |
|---|---|---|
| label | .eq-date-picker-label | |
| control | .eq-date-picker-control | The bordered box; data-invalid while a message shows. |
| input | .eq-date-picker-input | The text input. Carries required and the custom validity. |
| clear | .eq-date-picker-clear | Shown while there is text. |
| trigger | .eq-date-picker-trigger | A 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. |
|---|---|
| Enter | Rewrites a readable date in the shown format, then submits the form as natively. |
| Alt+ArrowDown | Opens the calendar from the text input. |
| Escape | In the text input, clears it. In the calendar, closes it. |
| EnterSpace | On the calendar button, opens the calendar with focus on the selected day. |
| ArrowsPageUpPageDownHomeEnd | Inside 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;
}
}