An accessible date picker built entirely from Pindoba components. A caption row shows the current period between prev/next Buttons — ‹ July 2024 › — and the period title is itself a Button that switches to the next coarser view (days → months → years → days). The grid is a real WAI-ARIA grid (role="grid" / row / gridcell, laid out with CSS grid) whose cells are plain roving-tabindex Buttons: every state (selected, today, highlighted, outside the month) is a Button variant, never a style override. All date math — month layout, week start, range selection, and keyboard navigation — runs through @internationalized/date, so locales, time zones, and leap years are handled correctly. The surface (background, border, radius, shadow, padding) delegates to Panel, so the same design tokens and variants apply here.
By default the calendar sizes to its content; set fullWidth to stretch it to the container.
Selection state lives in a single pure reducer. On Svelte it’s held in a rune; on Astro it’s driven by a small boot script that re-renders the grid on each change — no third-party calendar library is involved.
The grid follows the standard date-picker pattern: arrow keys move the focused day (±1 day / ±1 week), Home/End jump to the week edges, PageUp/PageDown step a month (hold Shift for a year), and Enter/Space select. In the months and years views the arrows move by month/year and PageUp/PageDown step a year/decade.
---
// GENERATED from packages/demos/calendar/default.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar />
</div>The caption title switches views: click it to go from the day grid to the months of the year, again for the decade, and once more to come back to the days. Clicking a month or a year drills back down. A single-view calendar (views={['days']}) renders the title as plain text.
Set captionLayout="dropdown" to replace the chevrons and the title with a month Select and a year Select. The year options span minValue–maxValue, or ten years either side of the displayed year when a side is unbounded; months that fall wholly outside the bounds are disabled. Keyboard paging in the grid keeps both Selects in sync.
---
// GENERATED from packages/demos/calendar/caption-dropdown.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar
captionLayout="dropdown"
minValue={new Date(2020, 0, 1)}
maxValue={new Date(2030, 11, 31)}
/>
</div>The Day / Month / Year segmented control (Choice) is off by default, since the title already switches views. Pass showViewTabs to add it back — in the card header in card mode, above the caption row with card={false}:
<Calendar showViewTabs />
The calendar is card-based: it renders inside its own Card.
card.heading / card.subheading (e.g. “Schedule a meeting”). Without one the card has no header row.‹ October 2026 ›, or the month/year Selects) sits at the top of the card content, above the grid and the selection label.showViewTabs) go in the header’s trailing slot, after any card.trailing.card.footer entries.Add contentBackground for the inset look:
---
// GENERATED from packages/demos/calendar/inset-card.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import Button from "@pindoba/astro-button";
const october = new Date(2026, 9, 1);
const booked = [
new Date(2026, 9, 6),
new Date(2026, 9, 7),
new Date(2026, 9, 14),
new Date(2026, 9, 21),
new Date(2026, 9, 22),
];
---
<Calendar
initialDate={october}
showReset
card={{
background: "surface.base",
contentBackground: "surface.peak",
border: "default",
eyebrow: "Studio B",
heading: "Session bookings",
footer: {},
}}
disabled={[new Date(2026, 9, 12)]}
highlights={[
{
match: booked,
feedback: "primary",
emphasis: "strong",
label: "Booked",
},
{
match: new Date(2026, 9, 12),
feedback: "warning",
emphasis: "subtle",
label: "Maintenance",
},
]}
>
<Button
slot="card-footer-trailing"
emphasis="primary"
feedback="primary"
size="sm"
>
Book a session
</Button>
</Calendar>passThrough.card reaches any Card slot (root, header, heading, content, footer…) and merges under the card prop’s own passThrough. card={false} renders the bare Panel root with the header and footer rows inside the calendar — the pre-card DOM. InputDatetime does this for the calendar in its popover.
<Calendar card={false} />
Migrating from the pre-card calendar: the root is now the Card — select it with
[data-card-kind="calendar"](it carries theid,data-calendar-bootand, once booted,data-calendar-initialized);[data-component="calendar"]only matches withcard={false}.passThrough.footerapplies only in bare mode (usepassThrough.cardin card mode), the Svelteelementbinding points at the card root, and apaddingoutside the card size scale falls back tosm.Migrating from the tabbed header: the period label moved out of the card heading into the caption title (still
[data-calendar-nav-label]); the chevrons point left/right; the Day/Month/Year tabs needshowViewTabs;passThrough.navis gone (usepassThrough.headerfor the caption row andpassThrough.titlefor the title); and the grid is<div>s with ARIA roles instead of a<table>— select cells with[role="gridcell"]or the[data-calendar-day]/[data-calendar-period]hooks.
Set range to true to let users pick a start and end date. While selecting, hovering a cell previews the range.
---
// GENERATED from packages/demos/calendar/range.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar showReset range />
</div>Range selection works at the leaf view’s granularity, and the leaf needs the coarser views for navigation context — so in range mode views is expanded to be downward-closed: a month range gets the years view, a day range gets months + years. (Single-select keeps standalone ['months']/['years'] pickers.)
A ['months', 'years'] picker with range selects a month range — pick a start and end month, using the years view to move between years. The committed value is inclusive of the whole end month (March–July commits 2026-03-01 … 2026-07-31).
---
// GENERATED from packages/demos/calendar/month-range.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar views={["months", "years"]} range showReset />
</div>A standalone ['years'] picker with range selects a year range, inclusive of the whole end year (2024–2027 commits 2024-01-01 … 2027-12-31).
---
// GENERATED from packages/demos/calendar/year-range.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar views={["years"]} range showReset />
</div>Set numberOfMonths (1–3) to render several month grids side by side. Keyboard navigation and range selection span the whole window; one caption row spans them (“May – June 2026”) and each grid is labelled with its own month.
---
// GENERATED from packages/demos/calendar/multi-month.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar range numberOfMonths={2} />
</div>Pass views to choose which views are available. The lowest-granularity entry is the leaf view — clicking a cell at the leaf emits a selection (first-of-clicked-month for a months leaf). In a non-leaf view, clicking a cell drills down toward the leaf.
---
// GENERATED from packages/demos/calendar/month-and-year.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar showReset views={["months", "years"]} />
</div>A single-view views={['months']} (or ['years']) renders a true month-only (or year-only) picker — no day grid, and the caption title is plain text — selecting at that granularity.
---
// GENERATED from packages/demos/calendar/month-picker.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<Calendar views={["months"]} showReset />
</div>Beyond the minValue/maxValue window, you can disable dates and tint them with semantic color. Both use one matching vocabulary — a DateMatcher — so you learn it once:
type DateMatcher =
| Date // one date
| Date[] // specific dates
| { from: Date; to?: Date } // an inclusive span (omit `to` for open-ended)
| { dayOfWeek: number[] } // weekdays, 0 = Sunday
| ((date: Date) => boolean); // a predicate (Svelte only — can't serialize)
Pass one matcher or an array (matches if any matches). The month(year, month) and year(year) helpers (exported from the package) return whole-period span matchers — month(2026, 7) matches all of July.
Matching is granularity-aware: in a day picker a matcher hits days, in a month-only picker it hits whole months, in a year-only picker whole years (a date inside July matches the July cell). { dayOfWeek } only applies to day cells. In a full day calendar the month/year views are pure navigation, governed by minValue/maxValue alone.
Disabling — disabled={matcher}. OR-combined with the minValue/maxValue bounds. Keyboard navigation skips disabled cells — arrow keys land on the next available date, and the cursor never rests on (nor does Enter commit) a disabled date.
Highlighting — highlights is a list of { match, feedback, emphasis?, label? } rules. match is a DateMatcher; feedback is a semantic palette (neutral/primary/success/warning/danger); emphasis is:
subtle (default) recolors just the text, in the palette’s accent — quietly communicate (“this is a holiday”, weekends).strong adds a fill tinted with the same palette behind the accent text — draw attention.Both keep the hover outline every day cell has. The first matching rule wins, and an optional label is appended to the cell’s aria-label. Highlights stay visually distinct from the selected state (solid fill) and today (a soft neutral fill) — a selected day always keeps its selection styling. neutral highlights read as grey text, close to the muted days of the adjacent month; pick another palette when the difference matters.
Astro note: predicate (function) matchers can’t be serialized, so on Astro they only apply to the server render, not to client-side navigation. Use the declarative forms (dates, spans,
{ dayOfWeek },month()/year()) on Astro; the function form is available on Svelte.
---
// GENERATED from packages/demos/calendar/holidays.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
const december = new Date(2026, 11, 1);
---
<div class={stack({ gap: "md" })}>
<Calendar
initialDate={december}
disabled={[new Date(2026, 11, 28), new Date(2026, 11, 29)]}
highlights={[
{
match: new Date(2026, 11, 25),
feedback: "danger",
emphasis: "strong",
label: "Christmas Day",
},
{
match: [new Date(2026, 11, 24), new Date(2026, 11, 31)],
feedback: "warning",
emphasis: "subtle",
label: "Holiday",
},
{
match: { dayOfWeek: [0, 6] },
feedback: "neutral",
emphasis: "subtle",
},
]}
/>
</div>In a month-only (views={['months']}) or year-only (views={['years']}) picker, the same disabled/highlights matchers hit whole months or years — reach for the month(2026, 7) / year(2025) helpers, or pass any date inside the period. The prev/next chevrons also disable automatically once minValue/maxValue (or fully-disabled matchers) leave no selectable period in that direction.
---
// GENERATED from packages/demos/calendar/month-picker-rules.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { month } from "@pindoba/core-calendar";
import { stack } from "@pindoba/styled-system/patterns";
const april = new Date(2026, 3, 1);
---
<div class={stack({ gap: "md" })}>
<Calendar
views={["months"]}
initialDate={april}
minValue={april}
maxValue={new Date(2026, 11, 31)}
highlights={[
{
match: [month(2026, 6), month(2026, 7), month(2026, 8)],
feedback: "warning",
emphasis: "subtle",
label: "Summer",
},
{
match: month(2026, 12),
feedback: "success",
emphasis: "strong",
label: "Launch",
},
]}
/>
</div>Show a caption (a price, a count) and/or dots under each day number with dayContent — a map keyed by ISO date. feedback tints the caption with that palette’s accent; dots is a count or one { feedback } per dot. A month with any content switches its days to taller rectangles so rows stay aligned, and the caption joins the day’s accessible name (labels.dayContent).
---
// GENERATED from packages/demos/calendar/day-content.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
import type { CalendarDayContentMap } from "@pindoba/core-calendar";
const july = new Date(2026, 6, 1);
const fares = [
11702, 9624, 10166, 10582, 10166, 9727, 11516, 11702, 10582, 11417, 10672,
10166, 12149, 12246, 13267, 10582, 10262, 12149, 12149, 13177, 12246, 13177,
10637, 12089, 11477, 11477, 11477, 12149, 11477, 10637, 10166,
];
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const prices = fares.reduce(
(map, fare, i) => ({
...map,
[`2026-07-${i + 1 < 10 ? `0${i + 1}` : i + 1}`]: {
caption: money.format(fare / 10),
...(fare <= 10200 ? { feedback: "success" as const } : {}),
},
}),
{} as CalendarDayContentMap,
);
const events = {
"2026-07-03": { dots: [{ feedback: "primary" }] },
"2026-07-08": { dots: [{ feedback: "primary" }, { feedback: "danger" }] },
"2026-07-14": { dots: 3 },
// Past `maxDots` (3) the rest collapse into a count: • • • +4.
"2026-07-16": { dots: 7 },
"2026-07-21": { dots: [{ feedback: "success" }] },
"2026-07-24": { dots: [{ feedback: "warning" }, { feedback: "primary" }] },
} as CalendarDayContentMap;
---
<div class={stack({ gap: "md" })}>
<Calendar initialDate={july} dayContent={prices} />
<Calendar initialDate={july} dayContent={events} />
</div>A day shows at most maxDots dots (default 3); past that the rest collapse into a +N count — dots: 7 renders • • • +4. The real total joins the day’s accessible name through labels.dayDots (default "{label}, {count} events"), or labels.dayDotsOne (default "{label}, {count} event") when Intl.PluralRules puts the count in the locale’s one category — so a day with dots is announced even when it has no caption. For languages with more plural forms, pass a function message. The caption and the +N use the 2xs font size token, muted, and switch to the contrast text on a selected day.
<Calendar dayContent={events} maxDots={5} />
Svelte, React and Vue also accept a (date) => content function. For fully custom content use the renderDayContent snippet (Svelte) / render prop (React), the dayContent scoped slot (Vue), or the Astro day-content slot — mark data-field="caption" and data-field="dots" elements and the calendar fills them for every day with content. The +N is appended to the dots field, or goes into your own data-field="dots-overflow" element when the template has one.
Set layout="strip" to show one row of days instead of the month grid — the short weekday above each number, with the strip’s own chevrons on either side. The caption row above still steps whole months (or shows the month/year Selects with captionLayout="dropdown"). The strip is days-only: its caption title is static and the view tabs are not shown.
stripDays sets how many days the row shows (default 5, clamped to 3–14).stripStep sets how far the strip’s chevrons move it: "page" a whole row (default) or "day" one day.The row is centered on the focused date when the calendar mounts and stays put while you click inside it; it only moves when the focus leaves it (the chevrons, the arrow keys, a month step, Today). Arrow keys move a day, Home/End jump to the row’s edges, and PageUp/PageDown step a month. Selection, ranges, disabled dates, highlights and day content all work the same as in the grid.
---
// GENERATED from packages/demos/calendar/day-strip.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
import type {
CalendarDayContentMap,
CalendarSelection,
} from "@pindoba/core-calendar";
const april2 = new Date(2023, 3, 2);
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const fares = [962, 1017, 1170, 1058, 973, 1142, 1215, 1327, 1064, 1148];
const prices = fares.reduce(
(map, fare, i) => ({
...map,
[`2023-04-${i + 1 < 10 ? `0${i + 1}` : i + 1}`]: {
caption: money.format(fare),
...(fare <= 1017 ? { feedback: "success" as const } : {}),
},
}),
{} as CalendarDayContentMap,
);
---
<div class={stack({ gap: "md" })}>
<Calendar
layout="strip"
initialDate={april2}
selection={{ kind: "single", date: april2 } as CalendarSelection}
/>
<Calendar
layout="strip"
stripDays={7}
stripStep="day"
initialDate={new Date(2023, 3, 4)}
dayContent={prices}
/>
</div>locale (a BCP 47 tag) formats every date the calendar shows or announces with Intl.DateTimeFormat — weekday headers, month names (the caption Selects included), the period label, and each day’s accessible name — and picks the locale’s first day of the week. For a non-English locale it also derives the words Intl knows: the view names (tabs, Select names) come from Intl.DisplayNames (pt-BR → Dia / Mês / Ano), and the Today button from Intl.RelativeTimeFormat (Hoje).
The rest of the strings (chevron and title names, Reset, the view-switcher and navigation group names, and the optional state-specific day names) stay English until you pass them in labels. An explicit label always wins over the locale-derived one, and with no locale and no labels the output is exactly the English defaults.
| Key | Default | Used for |
|---|---|---|
previous / next | "Previous" / "Next" | Chevron aria-labels. Values: {view} (days / months / years). |
today / reset | "Today" / "Reset" | Footer button text (today is locale-derived). |
viewDays / viewMonths / viewYears | "Day" / "Month" / "Year" | View-switcher tabs and the caption Selects’ names (locale-derived). |
switchView | "{label}, choose {target}" | The caption title’s aria-label. Values: {label}, {view}, {target} (the target view, lower-cased). |
viewSwitcher | "Calendar view" | aria-label of the view-switcher radiogroup. Not locale-derived. |
navigation | "Calendar navigation" | aria-label of the caption row (role="group"). Not locale-derived. |
day | "{date}" | Day-cell aria-label. Values: {date}, {weekday}, {day}, {month}, {year}. |
dayToday, daySelected, dayRangeStart, dayRangeEnd, dayUnavailable | falls back to day | Day-cell name in that state (same values). Precedence: range start/end → selected → today → unavailable. |
highlighted | "{label}, {highlight}" | How a highlight rule’s label joins a cell’s name. |
decade | "{start} – {end}" | The years view’s period label. |
Messages that take values accept either a {placeholder} template string or a function of the values:
const labels = {
previous: "Anterior",
next: ({ view }) => (view === "days" ? "Próximo mês" : "Próximo"),
reset: "Limpar",
dayToday: "{date}, hoje",
};
Astro note: only template strings survive hydration. The server render uses function labels too, but the client re-renders the grid after boot and a function can’t be serialized into the boot config — the calendar warns in dev and falls back to the default wording. Use
{placeholder}templates on Astro.
---
// GENERATED from packages/demos/calendar/locale.demo.tsx — edit the source, not this file.
import Calendar from "@pindoba/astro-calendar";
import { stack } from "@pindoba/styled-system/patterns";
import type { CalendarLabels } from "@pindoba/core-calendar";
const labels = {
previous: "Anterior",
next: "Próximo",
reset: "Limpar",
dayToday: "{date}, hoje",
} as CalendarLabels;
---
<div class={stack({ gap: "md" })}>
<Calendar locale="pt-BR" labels={labels} showReset showSelectionLabel />
</div>