Date and time
Calendar
A month grid for picking one day, with limits, unavailable days and full keyboard movement.
- Built on
Intl.DateTimeFormat@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.3 kB
- Dependencies
- React
November 2026
| 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 |
Code
import { Calendar } from "@/components/equip/calendar";
export default function Demo() {
return <Calendar size="s" defaultValue="2026-11-13" today="2026-11-10" locale="en-US" weekStartsOn={0} />;
}Examples
Booked and out of range
Unavailable days stay focusable so arrow keys never stall, but they cannot be picked and are struck through.
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
"use client";
import { Calendar } from "@/components/equip/calendar";
const booked = new Set(["2026-11-18", "2026-11-19", "2026-11-25"]);
export default function Example() {
return (
<Calendar
defaultMonth="2026-11"
today="2026-11-10"
min="2026-11-10"
max="2026-12-20"
isUnavailable={(iso) => booked.has(iso) || new Date(`${iso}T00:00Z`).getUTCDay() === 0}
/>
);
}Controlled
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 |
Invoice due Friday, 20 November 2026
Code
"use client";
import { useState } from "react";
import { Calendar } from "@/components/equip/calendar";
const spoken = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeZone: "UTC" });
export default function Example() {
const [date, setDate] = useState("2026-11-20");
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<Calendar value={date} onValueChange={setDate} today="2026-11-13" />
<p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
Invoice due {spoken.format(new Date(`${date}T00:00Z`))}
</p>
</div>
);
}Locale and week start
Month and weekday names come from Intl.DateTimeFormat, so any locale works without a translation table.
Dezember 2026
| M | D | M | D | 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 |
November 2026
| 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 |
Code
import { Calendar } from "@/components/equip/calendar";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "start" }}>
<Calendar locale="de-DE" weekStartsOn={1} defaultValue="2026-12-24" today="2026-11-13" />
<Calendar locale="en-US" weekStartsOn={0} defaultValue="2026-11-26" today="2026-11-13" />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
value | string | null | none | Selected day as an ISO date, such as "2026-11-13". |
defaultValue | string | null | none | Selected day when uncontrolled. |
onValueChange | (value: string) => void | none | Called with the ISO date of the picked day. |
min | string | none | Earliest selectable day. Keyboard movement stops here. |
max | string | none | Latest selectable day. Keyboard movement stops here. |
isUnavailable | (iso: string) => boolean | none | Days that can be focused but not picked, such as booked dates or weekends. |
month | string | none | Shown month, such as "2026-11". Controls paging. |
defaultMonth | string | none | First shown month when uncontrolled. Falls back to the value's month, then today's. |
onMonthChange | (month: string) => void | none | Called when the shown month changes. |
today | string | none | Today as an ISO date. When absent, the device date is read after mount so the server render stays stable. |
locale | string | "en-GB" | Locale for month, weekday and day names. |
weekStartsOn | 0 | 1 | 1 | 0 starts weeks on Sunday, 1 on Monday. |
size | "s" | "m" | "m" | Day cells of 24px or 32px. |
...rest | ComponentProps<"div"> | none | Every native div attribute, including ref. |
Anatomy
| root | .eq-calendar | |
|---|---|---|
| header | .eq-calendar-header | |
| title | .eq-calendar-title | Month and year, a polite live region. |
| nav | .eq-calendar-nav | Previous and next month buttons. |
| grid | .eq-calendar-grid | The table with role="grid"; slides in when the month changes. |
| weekday | .eq-calendar-weekday | Column header with the full weekday name in abbr. |
| day | .eq-calendar-day | A gridcell; aria-selected, aria-current="date" for today and data-outside for the months around. |
Keyboard
| ArrowLeftArrowRight | Moves to the previous or next day. |
|---|---|
| ArrowUpArrowDown | Moves to the same day in the previous or next week. |
| HomeEnd | Moves to the first or last day of the week. |
| PageUpPageDown | Moves to the same day in the previous or next month. |
| Shift+PageUpShift+PageDown | Moves to the same day in the previous or next year. |
| EnterSpace | Picks the focused day. |
| Tab | Moves between the month buttons and the one focusable day in the grid. |
Notes
- Follows the WAI-ARIA date grid pattern: one day is in the tab order and arrow keys move a roving focus.
- Each day's accessible name is the full date, such as "Friday 13 November 2026", and the month title is announced when it changes.
- Today carries a dot under the number as well as aria-current, so it does not depend on colour.
- Unavailable and out-of-range days stay focusable with aria-disabled and a strike-through, so movement never stalls.
- Days from the months around are shown dimmed and can be picked; picking one turns to its month.
- All maths is on UTC calendar dates, so a day never shifts with the time zone or daylight saving.
- The 24px size is the smallest the contract allows for a target; use it only where space is tight.
- Dates follow the locale prop, but the Previous month and Next month button labels are fixed English text.
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/calendar.tsx311 lines
"use client";
import { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type KeyboardEvent } from "react";
import "./calendar.css";
export type CalendarProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** Selected day as an ISO date, "2026-11-13". */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string) => void;
/** Earliest selectable day, ISO. */
min?: string;
/** Latest selectable day, ISO. */
max?: string;
/** Days that can be focused but not picked, such as booked dates. */
isUnavailable?: (iso: string) => boolean;
/** Shown month, "2026-11". */
month?: string;
defaultMonth?: string;
onMonthChange?: (month: string) => void;
/** Today as an ISO date. When absent it is read from the device after mount. */
today?: string;
locale?: string;
weekStartsOn?: 0 | 1;
size?: "s" | "m";
};
const pad = (n: number, length = 2) => String(n).padStart(length, "0");
function toDate(iso: string) {
const [y, m, d = 1] = iso.split("-").map(Number);
const date = new Date(0);
date.setUTCFullYear(y, m - 1, d);
return date;
}
function toISO(date: Date) {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
function daysIn(year: number, month: number) {
const date = new Date(0);
date.setUTCFullYear(year, month, 0);
return date.getUTCDate();
}
function addDays(iso: string, n: number) {
const date = toDate(iso);
date.setUTCDate(date.getUTCDate() + n);
return toISO(date);
}
function addMonths(iso: string, n: number) {
const [y, m, d] = iso.split("-").map(Number);
const total = y * 12 + m - 1 + n;
const year = Math.floor(total / 12);
const month = (total % 12) + 1;
return `${pad(year, 4)}-${pad(month)}-${pad(Math.min(d, daysIn(year, month)))}`;
}
const column = (iso: string, weekStartsOn: number) => (toDate(iso).getUTCDay() - weekStartsOn + 7) % 7;
function localToday() {
const now = new Date();
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function weeksOf(month: string, weekStartsOn: number) {
const first = `${month}-01`;
const [y, m] = month.split("-").map(Number);
const last = `${month}-${pad(daysIn(y, m))}`;
const end = addDays(last, 6 - column(last, weekStartsOn));
const weeks: string[][] = [];
for (let day = addDays(first, -column(first, weekStartsOn)); day <= end; ) {
const week: string[] = [];
for (let i = 0; i < 7; i++, day = addDays(day, 1)) week.push(day);
weeks.push(week);
}
return weeks;
}
export function Calendar({
value,
defaultValue = null,
onValueChange,
min,
max,
isUnavailable,
month,
defaultMonth,
onMonthChange,
today: todayProp,
locale = "en-GB",
weekStartsOn = 1,
size = "m",
className,
...rest
}: CalendarProps) {
const id = useId();
const [selectedState, setSelectedState] = useState(defaultValue);
const selected = value !== undefined ? value : selectedState;
const [deviceToday, setDeviceToday] = useState<string | null>(null);
const today = todayProp ?? deviceToday;
const [monthState, setMonthState] = useState<string | null>(
() => defaultMonth ?? (selected ?? todayProp ?? min)?.slice(0, 7) ?? null,
);
const shown = month ?? monthState;
const [focused, setFocused] = useState<string | null>(null);
const focusPending = useRef(false);
const [lastShown, setLastShown] = useState(shown);
const [slide, setSlide] = useState<"next" | "prev" | undefined>();
if (shown !== lastShown) {
setLastShown(shown);
setSlide(lastShown && shown ? (shown < lastShown ? "prev" : "next") : undefined);
}
useEffect(() => {
if (todayProp) return;
const now = localToday();
setDeviceToday(now);
setMonthState((current) => current ?? now.slice(0, 7));
}, [todayProp]);
const format = useMemo(
() => ({
title: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }),
day: new Intl.DateTimeFormat(locale, { day: "numeric", timeZone: "UTC" }),
full: new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }),
weekday: new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }),
weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }),
}),
[locale],
);
const disabled = (iso: string) =>
(min !== undefined && iso < min) || (max !== undefined && iso > max) || Boolean(isUnavailable?.(iso));
const clamp = (iso: string) => (min !== undefined && iso < min ? min : max !== undefined && iso > max ? max : iso);
function fallback(m: string) {
if (selected && selected.startsWith(m)) return selected;
if (today && today.startsWith(m)) return today;
const first = `${m}-01`;
return min !== undefined && min.startsWith(m) && first < min ? min : first;
}
const active = shown ? (focused && focused.startsWith(shown) ? focused : fallback(shown)) : null;
function changeMonth(next: string) {
if (month === undefined) setMonthState(next);
onMonthChange?.(next);
}
function move(target: string) {
const next = clamp(target);
if (next === active) return;
focusPending.current = true;
setFocused(next);
if (next.slice(0, 7) !== shown) changeMonth(next.slice(0, 7));
}
function step(n: number) {
if (!shown || !active) return;
const next = addMonths(`${shown}-01`, n).slice(0, 7);
setFocused(clamp(addMonths(active, n)));
changeMonth(next);
}
function select(iso: string) {
if (disabled(iso)) return;
if (iso.slice(0, 7) !== shown) move(iso);
else setFocused(iso);
if (value === undefined) setSelectedState(iso);
onValueChange?.(iso);
}
useEffect(() => {
if (!focusPending.current || !active) return;
focusPending.current = false;
const target = document.getElementById(`${id}-${active}`);
const current = document.activeElement;
// Only pull focus back into the grid it left, never away from something else on the page.
if (!target || (current && current !== document.body && !target.closest(".eq-calendar")?.contains(current))) return;
target.focus();
}, [active, id]);
function handleKeyDown(event: KeyboardEvent<HTMLTableElement>) {
if (!active) return;
let next: string;
switch (event.key) {
case "ArrowLeft":
next = addDays(active, -1);
break;
case "ArrowRight":
next = addDays(active, 1);
break;
case "ArrowUp":
next = addDays(active, -7);
break;
case "ArrowDown":
next = addDays(active, 7);
break;
case "Home":
next = addDays(active, -column(active, weekStartsOn));
break;
case "End":
next = addDays(active, 6 - column(active, weekStartsOn));
break;
case "PageUp":
next = addMonths(active, event.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(active, event.shiftKey ? 12 : 1);
break;
case "Enter":
case " ":
event.preventDefault();
select(active);
return;
default:
return;
}
event.preventDefault();
move(next);
}
const titleId = `${id}-title`;
const weeks = shown ? weeksOf(shown, weekStartsOn) : [];
const prevOff = !shown || (min !== undefined && addMonths(`${shown}-01`, -1).slice(0, 7) < min.slice(0, 7));
const nextOff = !shown || (max !== undefined && addMonths(`${shown}-01`, 1).slice(0, 7) > max.slice(0, 7));
const weekdays = Array.from({ length: 7 }, (_, i) => toDate(addDays("2026-11-01", weekStartsOn + i)));
return (
<div {...rest} className={["eq-calendar", className].filter(Boolean).join(" ")} data-size={size}>
<div className="eq-calendar-header">
<button
type="button"
className="eq-calendar-nav"
aria-label="Previous month"
disabled={prevOff}
onClick={() => step(-1)}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M10 3.5 5.5 8l4.5 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="eq-calendar-title" id={titleId} aria-live="polite">
{shown ? format.title.format(toDate(`${shown}-01`)) : ""}
</span>
<button
type="button"
className="eq-calendar-nav"
aria-label="Next month"
disabled={nextOff}
onClick={() => step(1)}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<table
key={shown ?? "empty"}
className="eq-calendar-grid"
role="grid"
aria-labelledby={titleId}
data-slide={slide}
onKeyDown={handleKeyDown}
>
<thead>
<tr>
{weekdays.map((date) => (
<th
key={date.getUTCDay()}
className="eq-calendar-weekday"
scope="col"
abbr={format.weekdayLong.format(date)}
>
{format.weekday.format(date)}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week[0]}>
{week.map((iso) => (
<td
key={iso}
id={`${id}-${iso}`}
className="eq-calendar-day"
role="gridcell"
tabIndex={iso === active ? 0 : -1}
aria-label={format.full.format(toDate(iso))}
aria-selected={iso === selected}
aria-disabled={disabled(iso) || undefined}
aria-current={iso === today ? "date" : undefined}
data-outside={!iso.startsWith(shown ?? "") || undefined}
onClick={() => select(iso)}
>
{format.day.format(toDate(iso))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}components/equip/calendar.css201 lines
@layer equip {
.eq-calendar {
--_cell: 32px;
--_nav: var(--eq-h-s);
display: inline-grid;
gap: var(--eq-space-2);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-calendar[data-size="s"] {
--_cell: 24px;
--_nav: 24px;
gap: var(--eq-space-1);
font-size: var(--eq-text-xs);
}
.eq-calendar-header {
display: flex;
align-items: center;
gap: var(--eq-space-1);
}
.eq-calendar-title {
flex: 1;
text-align: center;
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
letter-spacing: -0.005em;
}
.eq-calendar[data-size="s"] .eq-calendar-title {
font-size: var(--eq-text-s);
}
.eq-calendar-nav {
display: inline-grid;
place-items: center;
flex: none;
width: var(--_nav);
height: var(--_nav);
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
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-calendar-nav > svg {
width: 16px;
height: 16px;
}
.eq-calendar-nav:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-calendar-nav:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-calendar-nav:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-calendar-grid {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
transition:
translate var(--eq-dur-move) var(--eq-spring),
opacity var(--eq-dur) var(--eq-ease);
}
/* A new month slides in from the side it came from. */
@starting-style {
.eq-calendar-grid[data-slide="next"] {
translate: 6px 0;
opacity: 0;
}
.eq-calendar-grid[data-slide="prev"] {
translate: -6px 0;
opacity: 0;
}
}
.eq-calendar-weekday {
width: var(--_cell);
height: calc(var(--_cell) * 0.75);
padding: 0;
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight);
text-align: center;
vertical-align: middle;
}
.eq-calendar-day {
position: relative;
isolation: isolate;
width: var(--_cell);
height: var(--_cell);
padding: 0;
border-radius: var(--eq-radius-s);
text-align: center;
vertical-align: middle;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The selected fill grows into place rather than switching on. */
.eq-calendar-day::before {
content: "";
position: absolute;
inset: 1px;
z-index: -1;
border-radius: var(--eq-radius-s);
background: var(--eq-solid);
opacity: 0;
scale: 0.6;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
.eq-calendar-day[data-outside] {
color: var(--eq-ink-3);
}
.eq-calendar-day[aria-selected="true"] {
color: var(--eq-solid-ink);
font-weight: var(--eq-weight-strong);
}
.eq-calendar-day[aria-selected="true"]::before {
opacity: 1;
scale: 1;
}
/* Today: a dot under the number, so it does not rely on colour. */
.eq-calendar-day[aria-current="date"]::after {
content: "";
position: absolute;
left: 50%;
bottom: 3px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: var(--eq-radius-full);
background: currentColor;
}
.eq-calendar[data-size="s"] .eq-calendar-day[aria-current="date"]::after {
bottom: 2px;
width: 3px;
height: 3px;
margin-left: -1.5px;
}
@media (hover: hover) {
.eq-calendar-nav:hover:not(:disabled) {
background: var(--eq-tint);
color: var(--eq-ink);
}
.eq-calendar-day:hover:not([aria-selected="true"], [aria-disabled="true"]) {
background: var(--eq-tint);
}
}
.eq-calendar-day:active:not([aria-disabled="true"]) {
scale: var(--eq-press);
}
.eq-calendar-day:focus-visible {
z-index: 1;
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-calendar-day[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
text-decoration: line-through;
}
}