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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/calendar/Calendar.astro
---
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"> & {
  value?: string;
  month?: string;
  min?: string;
  max?: string;
  name?: string;
  locale?: string;
  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;
    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];
    };

    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));
      });
      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;
      if (previous) previous.disabled = !inRange(addDays(month, -1), min, max);
      if (next) next.disabled = !inRange(addMonths(month, 1), min, max);
      rendered = value;
    };

    const show = (iso: string) => {
      focused = clampToRange(iso, min, max);
      month = startOfMonth(focused);
      render();
      cells.find((cell) => cell.dataset.date === focused)?.focus();
    };

    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();
      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();
      show(to);
    });

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

    new MutationObserver(() => {
      const value = root.dataset.value ?? "";
      const asked = startOfMonth(root.dataset.month || month);
      if (value === rendered && asked === month) {
        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"] });

    if (root.dataset.autoMonth === "true") {
      focused = todayISO();
      month = startOfMonth(focused);
    }
    render();
  }

  onReadyOnce('[data-slot="calendar"]', wire);
</script>
src/components/ui/forms/calendar/CalendarCell.astro
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"td"> & {
  date: string;
  label: string;
  selected?: boolean;
  outside?: boolean;
  today?: boolean;
  disabled?: boolean;
  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
---
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"> & {
  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();

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
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  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
---
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
---
import "../../../../styles/structure.css";

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

type Props = HTMLAttributes<"th"> & {
  short: string;
  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
const DAY_MS = 86_400_000;

export const GRID_DAYS = 42;

export function isoDate(time: number): string {
  return new Date(time).toISOString().slice(0, 10);
}

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;
}

export function addDays(iso: string, delta: number): string {
  const t = parseISO(iso);
  return Number.isNaN(t) ? iso : isoDate(t + delta * DAY_MS);
}

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)));
}

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);
}

export function endOfWeek(iso: string, weekStartsOn = 1): string {
  return addDays(startOfWeek(iso, weekStartsOn), 6);
}

export function startOfMonth(iso: string): string {
  return Number.isNaN(parseISO(iso)) ? iso : `${iso.slice(0, 7)}-01`;
}

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));
}

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);
}

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;
}

export function formatMonth(iso: string, locale?: string): string {
  return new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  }).format(parseISO(iso));
}

export function formatFull(iso: string, locale?: string): string {
  return new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(
    parseISO(iso),
  );
}

export function weekdayNames(
  locale?: string,
  weekStartsOn = 1,
): { readonly short: string; readonly long: string }[] {
  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),
    };
  });
}

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.