Calendar
Free · MITHeadless, 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
npx astrocraft-ui add forms/calendarPlain-CSS theme — no build step
npx astrocraft-ui add forms/calendar --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/calendar --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/calendar --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/calendar --theme tailwind --bridge lumosLive 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.
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
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.
| Component | Slots | Variants | Runtime state | Native 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.
---
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>
---
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>
---
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>
---
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>
---
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>
---
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>
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())}`;
}
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.