Skip to main content
astrocraft-ui/ components · 101

Calendar

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/calendar

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/calendar --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/calendar --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/calendar --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/calendar --theme tailwind --bridge lumos

Live demo

Calendar

A real ARIA grid. Tab lands on ONE day, then ←/→ move by day,↑/↓ by week, Home/End to the ends of the week,PageUp/PageDown by month and with Shift by year. Arrow off the end of the month and it pages by itself; the caption is a live region, so paging is announced. The second one is bounded, and its nav buttons disable at the edges.

September 2026
MondayTuesdayWednesdayThursdayFridaySaturdaySunday
September 2026
MondayTuesdayWednesdayThursdayFridaySaturdaySunday

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`Calendar.astro``calendar` `calendar-value``data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6``data-date` `data-month` `data-value`—
`CalendarCell.astro``calendar-cell` `calendar-day``data-outside`: `true` when set
`data-today`: `true` when set
—`[aria-selected]` `[disabled]`
`CalendarGrid.astro``calendar-grid``data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6`—`[disabled]`
`CalendarHeader.astro``calendar-header` `calendar-label`———
`CalendarNav.astro``calendar-nav` `calendar-nav-next` `calendar-nav-previous`———
`CalendarWeekday.astro``calendar-weekday` `calendar-weekday-name`———

Source

What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/calendar/Calendar.astro
---
// src/components/ui/forms/calendar/Calendar.astro — headless primitive (see ../../README.md).
// A month view as a real ARIA grid: ↑/↓/←/→ move by day and week, Home/End jump to the ends of the
// week, PageUp/PageDown page by month (+Shift, by year), Enter and Space select — natively, because
// each day is a <button>. Only one of the 42 cells is in the tab order at a time (the roving
// tabindex the grid pattern requires), so Tab steps over the calendar rather than through it.
//
// WHAT MAKES THIS AFFORDABLE: the six-week grid is rendered ONCE, at build time, and paging FILLS
// those 42 cells rather than rebuilding them. No markup is generated in JavaScript anywhere in this
// library, and CalendarCell stays the only file that says what a day looks like. The date
// arithmetic lives in `calendar-grid.ts` so the same code runs in this frontmatter and in the script
// below — a navigated month cannot disagree with a server-rendered one — and is checked by
// `calendar-grid.test.ts`.
//
// Without JavaScript you still get a correctly labelled, readable month table; what you lose is
// paging and the keyboard. Give it `name` and it submits like any other field, from a real hidden
// input holding the ISO date.
//
// ponytail: single date only. Range selection lives in DateRangePicker, which decorates these same
// cells with `data-in-range` from outside rather than making this file understand two values —
// see DateRangePicker.astro. Week numbers and multi-month views are out of scope; render two
// Calendars if you need two months.
import type { HTMLAttributes } from "astro/types";

import { formatMonth, startOfMonth, todayISO } from "./calendar-grid";
import CalendarGrid from "./CalendarGrid.astro";
import CalendarHeader from "./CalendarHeader.astro";
import CalendarNav from "./CalendarNav.astro";

type Props = HTMLAttributes<"div"> & {
  /** The selected date, `YYYY-MM-DD`. */
  value?: string;
  /** Any date in the month to open on. Defaults to `value`'s month, else the current one. */
  month?: string;
  min?: string;
  max?: string;
  /** Submits the ISO date from a hidden input, so the calendar is a real form control. */
  name?: string;
  /** BCP-47 tag for the month, weekday and cell names. Defaults to the browser's. */
  locale?: string;
  /** 0 = Sunday … 6 = Saturday. */
  weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  previousLabel?: string;
  nextLabel?: string;
};

const {
  value,
  month,
  min,
  max,
  name,
  locale,
  weekStart = 1,
  previousLabel,
  nextLabel,
  class: className,
  ...rest
} = Astro.props;

const shown = startOfMonth(month ?? value ?? todayISO());
---

<div
  class={className}
  data-slot="calendar"
  data-month={shown}
  data-value={value}
  data-locale={locale}
  data-week-start={weekStart}
  data-min={min}
  data-max={max}
  data-auto-month={!month && !value ? "true" : undefined}
  {...rest}
>
  <CalendarHeader label={formatMonth(shown, locale)}>
    <CalendarNav previousLabel={previousLabel} nextLabel={nextLabel} />
  </CalendarHeader>
  <CalendarGrid
    month={shown}
    value={value}
    min={min}
    max={max}
    locale={locale}
    weekStart={weekStart}
  />
  {name && <input type="hidden" name={name} value={value} data-slot="calendar-value" />}
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import {
    addDays,
    addMonths,
    clampToRange,
    endOfWeek,
    formatFull,
    formatMonth,
    inRange,
    monthGrid,
    parseISO,
    startOfMonth,
    startOfWeek,
    todayISO,
  } from "./calendar-grid";

  function wire(root: HTMLElement) {
    const grid = root.querySelector<HTMLElement>('[data-slot="calendar-grid"]');
    if (!grid) return;
    const cells = [...grid.querySelectorAll<HTMLButtonElement>('[data-slot="calendar-day"]')];
    if (cells.length === 0) return;
    const caption = root.querySelector<HTMLElement>('[data-slot="calendar-label"]');
    const hidden = root.querySelector<HTMLInputElement>('[data-slot="calendar-value"]');
    const previous = root.querySelector<HTMLButtonElement>('[data-slot="calendar-nav-previous"]');
    const next = root.querySelector<HTMLButtonElement>('[data-slot="calendar-nav-next"]');

    const locale = root.dataset.locale || undefined;
    const weekStart = Number(root.dataset.weekStart ?? "1");
    const min = root.dataset.min || null;
    const max = root.dataset.max || null;

    let month = startOfMonth(root.dataset.month || todayISO());
    let focused = cells.find((cell) => cell.tabIndex === 0)?.dataset.date ?? month;
    /** The `data-value` the cells currently reflect — see the MutationObserver at the bottom. */
    let rendered = root.dataset.value ?? "";

    const flag = (el: HTMLElement, name: "outside" | "today", on: boolean) => {
      if (on) el.dataset[name] = "true";
      else delete el.dataset[name];
    };

    /** Fill the 42 cells for `month`. The only DOM write path — nothing here creates an element. */
    const render = () => {
      const days = monthGrid(month, weekStart);
      const key = month.slice(0, 7);
      const today = todayISO();
      const value = root.dataset.value ?? "";
      cells.forEach((cell, i) => {
        const iso = days[i];
        cell.dataset.date = iso;
        cell.textContent = String(Number(iso.slice(8, 10)));
        cell.setAttribute("aria-label", formatFull(iso, locale));
        cell.disabled = !inRange(iso, min, max);
        cell.tabIndex = iso === focused ? 0 : -1;
        flag(cell, "outside", key !== iso.slice(0, 7));
        flag(cell, "today", iso === today);
        cell.parentElement?.setAttribute("aria-selected", String(iso === value));
      });
      // The roving date can sit outside the rendered grid (paging by year, say). Something must stay
      // tabbable or the calendar drops out of the tab order entirely.
      if (!cells.some((cell) => cell.tabIndex === 0)) cells[0].tabIndex = 0;

      const label = formatMonth(month, locale);
      if (caption) caption.textContent = label;
      grid.setAttribute("aria-label", label);
      root.dataset.month = month;
      // The last day of the previous month / the first of the next: if that day is out of bounds,
      // so is every day the button would page to.
      if (previous) previous.disabled = !inRange(addDays(month, -1), min, max);
      if (next) next.disabled = !inRange(addMonths(month, 1), min, max);
      rendered = value;
    };

    /** Move the roving date (paging the view if it left the month) and take focus with it. */
    const show = (iso: string) => {
      focused = clampToRange(iso, min, max);
      month = startOfMonth(focused);
      render();
      cells.find((cell) => cell.dataset.date === focused)?.focus();
    };

    /** Page the view without moving focus — the nav buttons must keep it, the caption announces. */
    const page = (delta: number) => {
      month = startOfMonth(addMonths(month, delta));
      const moved = clampToRange(addMonths(focused, delta), min, max);
      focused = moved.slice(0, 7) === month.slice(0, 7) ? moved : month;
      render();
    };

    const select = (iso: string) => {
      root.dataset.value = iso;
      if (hidden) hidden.value = iso;
      focused = iso;
      render();
      // A plain bubbling `change`, like every other value-carrying primitive here: the value is on
      // the element (`data-value`, and the hidden input), so a listener needs nothing from the event.
      root.dispatchEvent(new Event("change", { bubbles: true }));
    };

    const dayAt = (target: EventTarget | null) =>
      target instanceof Element
        ? target.closest<HTMLButtonElement>('[data-slot="calendar-day"]')
        : null;

    grid.addEventListener("click", (event) => {
      const cell = dayAt(event.target);
      if (cell && !cell.disabled && cell.dataset.date) select(cell.dataset.date);
    });

    grid.addEventListener("keydown", (event) => {
      const from = dayAt(event.target)?.dataset.date;
      if (!from) return;
      const moves: Record<string, () => string> = {
        ArrowLeft: () => addDays(from, -1),
        ArrowRight: () => addDays(from, 1),
        ArrowUp: () => addDays(from, -7),
        ArrowDown: () => addDays(from, 7),
        Home: () => startOfWeek(from, weekStart),
        End: () => endOfWeek(from, weekStart),
        PageUp: () => addMonths(from, event.shiftKey ? -12 : -1),
        PageDown: () => addMonths(from, event.shiftKey ? 12 : 1),
      };
      const to = moves[event.key]?.();
      if (!to) return;
      event.preventDefault(); // ArrowDown and PageDown would otherwise scroll the page
      show(to);
    });

    previous?.addEventListener("click", () => page(-1));
    next?.addEventListener("click", () => page(1));

    // DRIVEN FROM OUTSIDE BY THE DOM, not by an exported API — the same contract as showing a Toast
    // with `toast.hidden = false`. Write `data-value` or `data-month` on the root (DatePicker does)
    // and the grid follows; a changed value pulls the view to its month. The comparison against what
    // was last rendered is what keeps `render()`'s own writes from re-entering.
    new MutationObserver(() => {
      const value = root.dataset.value ?? "";
      const asked = startOfMonth(root.dataset.month || month);
      if (value === rendered && asked === month) {
        // An outside writer may hand `data-month` any day in the month already shown (DatePicker
        // hands it the whole date). Nothing needs re-rendering, but the attribute is public, so it
        // is put back in its canonical first-of-the-month form. The write re-enters here once and
        // then matches, so it settles.
        if (root.dataset.month !== month) root.dataset.month = month;
        return;
      }
      if (value !== rendered && !Number.isNaN(parseISO(value))) focused = value;
      month = value !== rendered && focused === value ? startOfMonth(value) : asked;
      render();
    }).observe(root, { attributeFilter: ["data-value", "data-month"] });

    // A statically built page bakes the BUILD date into the default month. When the author named
    // neither `month` nor `value`, correct it on load — otherwise a site built in September still
    // opens on September in February, and `data-today` marks a day that has long gone.
    if (root.dataset.autoMonth === "true") {
      focused = todayISO();
      month = startOfMonth(focused);
    }
    render();
  }

  onReadyOnce('[data-slot="calendar"]', wire);
</script>
src/components/ui/forms/calendar/CalendarCell.astro
---
// src/components/ui/forms/calendar/CalendarCell.astro — Calendar compound part (see ../../README.md).
// One day of the grid: a `role="gridcell"` <td> carrying the selected state, wrapping the <button>
// that is actually focusable and clickable.
//
// The split is deliberate. `aria-selected` belongs on the gridcell (that is the cell's state), while
// the thing a user operates has to be a real button so Enter and Space activate it without a line of
// JavaScript. Only ONE button in the whole grid has `tabindex="0"` — that is the roving tabindex the
// grid pattern requires: Tab enters the calendar once and the arrow keys move within it, rather than
// making a user press Tab forty-two times to get past a month.
//
// Every attribute below is also written by Calendar's script when you page to another month: the 42
// cells are filled, never rebuilt, so this file stays the single source of a day's markup.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"td"> & {
  /** The day this cell shows, `YYYY-MM-DD`. */
  date: string;
  /** The cell's accessible name — the full localised date, since the text is only a number. */
  label: string;
  selected?: boolean;
  /** Belongs to the previous or next month: shown, operable, and marked for a theme to dim. */
  outside?: boolean;
  today?: boolean;
  disabled?: boolean;
  /** The one cell in the grid that Tab reaches. */
  tabbable?: boolean;
};

const {
  date,
  label,
  selected,
  outside,
  today,
  disabled,
  tabbable,
  class: className,
  ...rest
} = Astro.props;
---

<td
  role="gridcell"
  aria-selected={selected ? "true" : "false"}
  data-slot="calendar-cell"
  class={className}
  {...rest}
>
  <button
    type="button"
    data-slot="calendar-day"
    data-date={date}
    data-outside={outside ? "true" : undefined}
    data-today={today ? "true" : undefined}
    tabindex={tabbable ? 0 : -1}
    aria-label={label}
    disabled={disabled}
  >
    {Number(date.slice(8, 10))}
  </button>
</td>
src/components/ui/forms/calendar/CalendarGrid.astro
---
// src/components/ui/forms/calendar/CalendarGrid.astro — Calendar compound part (see ../../README.md).
// The month itself: a real <table role="grid"> of six rows of CalendarCell, with a row of
// CalendarWeekday heads. Zero-JS on its own — it renders a readable, correctly-labelled month table
// with no script at all; Calendar's script adds the keyboard and the paging.
//
// SIX ROWS, ALWAYS, even for a February that fits in four. Two reasons, one visual and one
// structural: the calendar does not change height as you page, and the script can FILL 42 existing
// cells instead of building new ones — so no markup is ever generated in JavaScript and CalendarCell
// stays the only place a day's markup is written.
//
// The grid is `aria-label`led with the month rather than a generic word, because "September 2026"
// is what tells a screen reader user which month they just paged to; the script rewrites it in step
// with the caption.
import type { HTMLAttributes } from "astro/types";

import {
  formatFull,
  formatMonth,
  inRange,
  monthGrid,
  startOfMonth,
  todayISO,
  weekdayNames,
} from "./calendar-grid";
import CalendarCell from "./CalendarCell.astro";
import CalendarWeekday from "./CalendarWeekday.astro";

type Props = HTMLAttributes<"table"> & {
  /** Any date in the month to render. */
  month: string;
  value?: string;
  min?: string;
  max?: string;
  locale?: string;
  weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};

const { month, value, min, max, locale, weekStart = 1, class: className, ...rest } = Astro.props;

const first = startOfMonth(month);
const key = first.slice(0, 7);
const days = monthGrid(first, weekStart);
const today = todayISO();

// The one cell Tab reaches: the selected day if it is in this month, else today, else the 1st.
// A grid where Tab lands on a day in the previous month is disorienting, which is why `outside`
// cells never win here.
const inMonth = (iso?: string) => Boolean(iso) && iso!.slice(0, 7) === key;
const tabbable = inMonth(value) ? value! : inMonth(today) ? today : first;

const rows = Array.from({ length: 6 }, (_, r) => days.slice(r * 7, r * 7 + 7));
---

<table
  role="grid"
  aria-label={formatMonth(first, locale)}
  data-slot="calendar-grid"
  class={className}
  {...rest}
>
  <thead>
    <tr>
      {
        weekdayNames(locale, weekStart).map((d) => (
          <CalendarWeekday short={d.short} long={d.long} />
        ))
      }
    </tr>
  </thead>
  <tbody>
    {
      rows.map((week) => (
        <tr>
          {week.map((iso) => (
            <CalendarCell
              date={iso}
              label={formatFull(iso, locale)}
              selected={iso === value}
              outside={iso.slice(0, 7) !== key}
              today={iso === today}
              disabled={!inRange(iso, min, max)}
              tabbable={iso === tabbable}
            />
          ))}
        </tr>
      ))
    }
  </tbody>
</table>
src/components/ui/forms/calendar/CalendarHeader.astro
---
// src/components/ui/forms/calendar/CalendarHeader.astro — Calendar compound part (see ../../README.md).
// The month caption plus whatever you put beside it (Calendar puts CalendarNav there).
//
// The caption is `aria-live="polite"`, and that is the part that earns this file. Paging with the
// nav buttons or PageUp/PageDown changes which month the 42 cells describe, but focus stays where it
// was — so without a live region a screen reader user presses "next month" and is told nothing at
// all. Polite, not assertive: it should follow the keystroke, not talk over it.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** The month caption, e.g. "September 2026". Calendar's script rewrites it on navigation. */
  label: string;
};

const { label, class: className, ...rest } = Astro.props;
---

<div data-slot="calendar-header" class={className} {...rest}>
  <div data-slot="calendar-label" aria-live="polite">{label}</div>
  <slot />
</div>
src/components/ui/forms/calendar/CalendarNav.astro
---
// src/components/ui/forms/calendar/CalendarNav.astro — Calendar compound part (see ../../README.md).
// The previous / next month buttons. Calendar's script pages the grid and disables whichever button
// would step past `min` / `max` — a disabled button is the honest signal that there is nothing
// further, and it also keeps a keyboard user from landing on a month with no reachable days.
//
// The labels are props rather than slotted text because they are the buttons' ACCESSIBLE NAMES and
// the glyphs are `aria-hidden` — "‹" announced as "single left-pointing angle quotation mark" is
// what happens when an icon button is left to name itself. Translate them by passing them.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  previousLabel?: string;
  nextLabel?: string;
};

const {
  previousLabel = "Previous month",
  nextLabel = "Next month",
  class: className,
  ...rest
} = Astro.props;
---

<div data-slot="calendar-nav" class={className} {...rest}>
  <button type="button" data-slot="calendar-nav-previous" aria-label={previousLabel}>
    <slot name="previous"
      ><svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="m15 18-6-6 6-6"></path>
      </svg></slot
    >
  </button>
  <button type="button" data-slot="calendar-nav-next" aria-label={nextLabel}>
    <slot name="next"
      ><svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="m9 18 6-6-6-6"></path>
      </svg></slot
    >
  </button>
</div>
src/components/ui/forms/calendar/CalendarWeekday.astro
---
// src/components/ui/forms/calendar/CalendarWeekday.astro — Calendar compound part (see ../../README.md).
// One column head, and it renders the weekday TWICE on purpose: the short name for the eye, the full
// name for assistive tech. A screen reader announcing the column of a date cell would otherwise read
// the abbreviation as written — "Mon", "Tue" — which is either spelled out letter by letter or
// pronounced as a word, depending on the reader.
//
// `<th abbr="Monday">` is the native answer to exactly this and is the reason the markup is not
// three elements deep, but support for it across screen readers is uneven enough that the full name
// is also rendered and visually hidden by structure.css. `scope="col"` is what associates all six
// rows of cells below with this head.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"th"> & {
  /** The abbreviation shown, e.g. "Mon". */
  short: string;
  /** The full name announced, e.g. "Monday". */
  long: string;
};

const { short, long, class: className, ...rest } = Astro.props;
---

<th scope="col" abbr={long} data-slot="calendar-weekday" class={className} {...rest}>
  <span aria-hidden="true">{short}</span><span data-slot="calendar-weekday-name">{long}</span>
</th>
src/components/ui/forms/calendar/calendar-grid.ts
// src/components/ui/forms/calendar/calendar-grid.ts — the date arithmetic behind Calendar (and, through
// it, DatePicker), in a plain module so it is unit-checkable without a DOM (see calendar-grid.test.ts)
// and so the SAME code runs twice: once in Calendar's frontmatter to render the first month at build
// time, and again in its bundled <script> when you page to another month. One implementation, so a
// navigated month can never disagree with a server-rendered one.
//
// Everything here is UTC. A calendar grid is about calendar days, not instants, and local-time
// arithmetic gets this wrong exactly twice a year: in a zone that springs forward at midnight,
// `new Date(y, m, d + 1)` lands on the same day at 23:00 and the grid repeats a date. Parsing
// `YYYY-MM-DD` with an explicit `Z` and stepping in whole days removes the class of bug.
//
// Formatting is `Intl`, never a table of month names: it is in the platform, it is localised, and it
// is available in Node too — which is what lets the same functions run in frontmatter.

const DAY_MS = 86_400_000;

/** The 6×7 grid every month renders into. Always 42 days — see `monthGrid`. */
export const GRID_DAYS = 42;

/** `YYYY-MM-DD` for a UTC timestamp. */
export function isoDate(time: number): string {
  return new Date(time).toISOString().slice(0, 10);
}

/**
 * Parse `YYYY-MM-DD` as UTC midnight; `NaN` for anything else, including a well-formed impossible
 * date like `2026-02-30`. Callers treat `NaN` as "no date", which is how an empty input arrives.
 *
 * The round-trip is the check that matters: V8 happily parses `2026-02-30` and hands back 2 March,
 * so a component that trusted the format alone would show a date the user never typed.
 */
export function parseISO(iso: string | null | undefined): number {
  if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return NaN;
  const t = Date.parse(`${iso}T00:00:00Z`);
  return !Number.isNaN(t) && isoDate(t) === iso ? t : NaN;
}

/** `iso` moved by whole days. Invalid input passes straight back out, so callers can chain. */
export function addDays(iso: string, delta: number): string {
  const t = parseISO(iso);
  return Number.isNaN(t) ? iso : isoDate(t + delta * DAY_MS);
}

/**
 * `iso` moved by whole months, with the day CLAMPED to the target month's length — 31 January plus
 * one month is 28 February, not 3 March. Rolling over is the behavior people report as a bug when
 * paging a calendar, because the day under the cursor silently changes month twice.
 */
export function addMonths(iso: string, delta: number): string {
  const t = parseISO(iso);
  if (Number.isNaN(t)) return iso;
  const d = new Date(t);
  const year = d.getUTCFullYear();
  const month = d.getUTCMonth() + delta;
  const last = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
  return isoDate(Date.UTC(year, month, Math.min(d.getUTCDate(), last)));
}

/** The first day of `iso`'s week. `weekStartsOn` is 0 = Sunday … 6 = Saturday. */
export function startOfWeek(iso: string, weekStartsOn = 1): string {
  const t = parseISO(iso);
  if (Number.isNaN(t)) return iso;
  const back = (new Date(t).getUTCDay() - weekStartsOn + 7) % 7;
  return isoDate(t - back * DAY_MS);
}

/** The last day of `iso`'s week. */
export function endOfWeek(iso: string, weekStartsOn = 1): string {
  return addDays(startOfWeek(iso, weekStartsOn), 6);
}

/** The first of `iso`'s month, i.e. the value Calendar keeps in `data-month`. */
export function startOfMonth(iso: string): string {
  return Number.isNaN(parseISO(iso)) ? iso : `${iso.slice(0, 7)}-01`;
}

/**
 * The 42 dates of the grid that shows `iso`'s month: the month itself, padded at the front to the
 * week start and at the back to a full six rows.
 *
 * ALWAYS six rows, even for a 28-day February that fits in four. A grid whose row count changes as
 * you page makes the whole component jump under the pointer, and — the reason it is here rather than
 * in a theme — lets the script fill a fixed set of cells on navigation instead of building DOM,
 * which is what keeps markup generation out of JavaScript entirely (contract rule 4's neighbour).
 *
 * @param iso - any date in the month to show
 * @param weekStartsOn - 0 = Sunday … 6 = Saturday
 * @returns 42 `YYYY-MM-DD` strings, in reading order
 * @example monthGrid("2026-09-11", 1)[0] // => "2026-08-31" (the Monday before 1 September)
 */
export function monthGrid(iso: string, weekStartsOn = 1): string[] {
  const first = startOfWeek(startOfMonth(iso), weekStartsOn);
  const t = parseISO(first);
  if (Number.isNaN(t)) return [];
  return Array.from({ length: GRID_DAYS }, (_, i) => isoDate(t + i * DAY_MS));
}

/** Whether `iso` falls inside an optional `min`/`max` pair. Empty bounds mean unbounded. */
export function inRange(iso: string, min?: string | null, max?: string | null): boolean {
  const t = parseISO(iso);
  if (Number.isNaN(t)) return false;
  const lo = parseISO(min);
  const hi = parseISO(max);
  return !(t < lo) && !(t > hi);
}

/** `iso` pulled inside `min`/`max`. Used when paging lands the focused day out of bounds. */
export function clampToRange(iso: string, min?: string | null, max?: string | null): string {
  const t = parseISO(iso);
  if (Number.isNaN(t)) return iso;
  const lo = parseISO(min);
  const hi = parseISO(max);
  if (!Number.isNaN(lo) && t < lo) return isoDate(lo);
  if (!Number.isNaN(hi) && t > hi) return isoDate(hi);
  return iso;
}

/** `"September 2026"` — the heading, localised, with no month-name table anywhere. */
export function formatMonth(iso: string, locale?: string): string {
  return new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  }).format(parseISO(iso));
}

/** `"Friday, 11 September 2026"` — a cell's accessible name; the visible text is just the number. */
export function formatFull(iso: string, locale?: string): string {
  return new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(
    parseISO(iso),
  );
}

/** Weekday names in grid order, `short` for the column head and `long` for its `abbr`. */
export function weekdayNames(
  locale?: string,
  weekStartsOn = 1,
): { readonly short: string; readonly long: string }[] {
  // 2024-01-07 was a Sunday, so offsetting from it gives any week start without a lookup table.
  const sunday = Date.UTC(2024, 0, 7);
  return Array.from({ length: 7 }, (_, i) => {
    const day = new Date(sunday + ((weekStartsOn + i) % 7) * DAY_MS);
    return {
      short: new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(day),
      long: new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(day),
    };
  });
}

/**
 * Today, as the user's calendar reckons it — LOCAL fields, not `toISOString()`. Every other function
 * here is UTC because a grid is about calendar days, but "today" is the one value that has to agree
 * with the wall clock in the room: at 21:00 in New York the UTC date is already tomorrow, and a
 * calendar that highlights tomorrow is simply wrong.
 */
export function todayISO(): string {
  const d = new Date();
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
src/components/ui/forms/calendar/index.ts
import Calendar from "./Calendar.astro";
import CalendarCell from "./CalendarCell.astro";
import CalendarGrid from "./CalendarGrid.astro";
import CalendarHeader from "./CalendarHeader.astro";
import CalendarNav from "./CalendarNav.astro";
import CalendarWeekday from "./CalendarWeekday.astro";

export { Calendar, CalendarCell, CalendarGrid, CalendarHeader, CalendarNav, CalendarWeekday };
export default Calendar;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/calendar — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.