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
SMTWTFS
1234567
891011121314
15161718192021
22232425262728
293012345
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
MTWTFSS
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456
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
MTWTFSS
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456

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
MDMDFSS
30123456
78910111213
14151617181920
21222324252627
28293031123
November 2026
SMTWTFS
1234567
891011121314
15161718192021
22232425262728
293012345
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

PropTypeDefaultWhat it does
valuestring | nullnoneSelected day as an ISO date, such as "2026-11-13".
defaultValuestring | nullnoneSelected day when uncontrolled.
onValueChange(value: string) => voidnoneCalled with the ISO date of the picked day.
minstringnoneEarliest selectable day. Keyboard movement stops here.
maxstringnoneLatest selectable day. Keyboard movement stops here.
isUnavailable(iso: string) => booleannoneDays that can be focused but not picked, such as booked dates or weekends.
monthstringnoneShown month, such as "2026-11". Controls paging.
defaultMonthstringnoneFirst shown month when uncontrolled. Falls back to the value's month, then today's.
onMonthChange(month: string) => voidnoneCalled when the shown month changes.
todaystringnoneToday as an ISO date. When absent, the device date is read after mount so the server render stays stable.
localestring"en-GB"Locale for month, weekday and day names.
weekStartsOn0 | 110 starts weeks on Sunday, 1 on Monday.
size"s" | "m""m"Day cells of 24px or 32px.
...restComponentProps<"div">noneEvery native div attribute, including ref.

Anatomy

root.eq-calendar
header.eq-calendar-header
title.eq-calendar-titleMonth and year, a polite live region.
nav.eq-calendar-navPrevious and next month buttons.
grid.eq-calendar-gridThe table with role="grid"; slides in when the month changes.
weekday.eq-calendar-weekdayColumn header with the full weekday name in abbr.
day.eq-calendar-dayA gridcell; aria-selected, aria-current="date" for today and data-outside for the months around.

Keyboard

ArrowLeftArrowRightMoves to the previous or next day.
ArrowUpArrowDownMoves to the same day in the previous or next week.
HomeEndMoves to the first or last day of the week.
PageUpPageDownMoves to the same day in the previous or next month.
Shift+PageUpShift+PageDownMoves to the same day in the previous or next year.
EnterSpacePicks the focused day.
TabMoves 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;
  }
}

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