Calendar
@ailuracode/alpine-calendar
Lightweight calendar logic for Alpine.js, powered by date-fns. Registers callable magic $calendar that returns an independent calendar instance. The plugin does not render UI — you own the markup and styles.
Install
pnpm add @ailuracode/alpine-calendar @ailuracode/alpine-core alpinejsDate selection uses @ailuracode/alpine-selection internally (ISO date keys, single/multiple/range modes).
Quick start
import Alpine from "alpinejs";import calendar from "@ailuracode/alpine-calendar";
Alpine.plugin(calendar);Alpine.start();TypeScript consumers can add:
/// <reference types="@ailuracode/alpine-calendar/global" />Magic API
Callable factory:
| Usage | Returns | Description |
|---|---|---|
$calendar(options?) |
CalendarInstance |
Creates an independent calendar logic instance |
Each call returns a new object with its own month, selection, and grid data.
Avoiding name collisions
If your application already owns a $calendar magic or another toolkit plugin registers on that name, rename the integration surface without touching the controller:
Alpine.plugin(calendarPlugin({ magicKey: "datePicker" })); // → $datePickerThe exposed constant DEFAULT_CALENDAR_MAGIC_KEY keeps the rename discoverable from TypeScript.
Options
| Option | Type | Default | Description |
|---|---|---|---|
locale |
Locale |
enUS |
date-fns locale for labels and formatting |
weekStartsOn |
0–6 |
0 |
First day of the week (Sunday = 0) |
minDate |
Date |
— | Earliest selectable date |
maxDate |
Date |
— | Latest selectable date |
mode |
"single" | "range" | "multiple" |
"single" |
Selection behavior |
month |
Date |
current month | Initial visible month |
selected |
Date | Date[] | { from?, to? } | null |
null |
Initial selection |
disabled |
CalendarMatcher | CalendarMatcher[] |
— | Dates that cannot be selected |
numberOfMonths |
number |
1 |
Consecutive months to display (clamped to 1–12) |
dateFns |
CalendarDateFnsOptions |
— | Extra date-fns context (locale, in, week options, format tokens) |
Disabling dates
Use disabled with one matcher or an array. If any matcher matches, the date is disabled.
| Matcher | Description |
|---|---|
true |
Disable all dates |
Date |
Disable a single day |
Date[] |
Disable specific days |
{ from, to } |
Disable an inclusive range (endpoints included) |
{ only: { from, to } } |
Disable everything outside the inclusive range |
{ before: Date } |
Disable dates strictly before the date (exclusive) |
{ after: Date } |
Disable dates strictly after the date (exclusive) |
{ before, after } |
Disable dates strictly between the bounds (exclusive endpoints) |
{ dayOfWeek: Day | Day[] } |
Disable weekdays (0 = Sunday) |
(date) => boolean |
Custom predicate — return true to disable |
CalendarMatcher[] |
Combine any of the above |
$calendar({ minDate: new Date(2024, 0, 1), maxDate: new Date(2024, 11, 31), disabled: [ { dayOfWeek: [0, 6] }, { from: new Date(2024, 11, 24), to: new Date(2024, 11, 26) }, new Date(2024, 6, 4), { only: { from: new Date(2024, 2, 1), to: new Date(2024, 2, 31) } }, (date) => date.getMonth() === 7, ],});minDate / maxDate still apply on top of disabled.
date-fns context
Pass date-fns options through dateFns. Top-level locale and weekStartsOn are shortcuts that merge into this context.
Supported fields include:
localeweekStartsOnfirstWeekContainsDateuseAdditionalWeekYearTokensuseAdditionalDayOfYearTokensin— context function for extensions such asTZDate
import { tz } from "@date-fns/tz";
$calendar({ locale: es, weekStartsOn: 1, dateFns: { in: tz("Europe/Madrid"), },});The resolved context is exposed on each instance as cal.dateFns and is passed to every internal date-fns call.
Instance API
State
| Property | Type | Description |
|---|---|---|
month |
Date |
Anchor month — first visible month (months[0].month) |
numberOfMonths |
number |
Count of consecutive visible months |
months |
CalendarMonthView[] |
Month grids for rendering (month + weeks per entry) |
mode |
CalendarMode |
Selection mode |
selected |
Date | Date[] | { from?, to? } | null |
Current selection |
locale |
Locale |
Active locale |
weekStartsOn |
0–6 |
Week start day |
dateFns |
CalendarDateFnsOptions |
Resolved date-fns context |
weeks |
CalendarDay[][] |
First month grid — same as months[0].weeks |
weekdayLabels |
string[] |
Localized weekday headers |
Each CalendarMonthView exposes:
| Property | Type | Description |
|---|---|---|
month |
Date |
Start of that month |
weeks |
CalendarDay[][] |
Grid for that month |
Day cells in every month share the same global selection. isCurrentMonth is computed relative to each month view, not only the anchor.
Navigation
| Method | Description |
|---|---|
prevMonth() |
Move the anchor back by numberOfMonths months |
nextMonth() |
Move the anchor forward by numberOfMonths months |
goToMonth(date) |
Jump to the month containing date (anchor = start of that month) |
goToToday() |
Jump to the current month |
Selection
| Method | Description |
|---|---|
select(date) |
Select or toggle a date (mode-dependent) |
clear() |
Clear the current selection |
matches(date, matcher) |
Check whether a date matches a matcher |
Queries
| Method | Description |
|---|---|
isSelected(date) |
Whether the date is selected |
isDisabled(date) |
Whether the date is outside minDate / maxDate |
isToday(date) |
Whether the date is today |
isSameMonth(date, month?) |
Whether the date belongs to a month |
isInRange(date) |
Range middle day (range mode) |
isRangeStart(date) |
Range start endpoint |
isRangeEnd(date) |
Range end endpoint |
Formatting
| Method | Description |
|---|---|
format(date, pattern) |
Format a date with the active locale |
formatMonth(month?) |
Format a month label (LLLL yyyy) |
formatYear(month?) |
Format a year label |
HTML examples
Single-date picker
<div x-data="{ cal: $calendar({ weekStartsOn: 1 }) }"> <div class="calendar-header"> <button type="button" @click="cal.prevMonth()">Previous</button> <strong x-text="cal.formatMonth()"></strong> <button type="button" @click="cal.nextMonth()">Next</button> </div>
<div class="calendar-weekdays"> <template x-for="label in cal.weekdayLabels" :key="label"> <span x-text="label"></span> </template> </div>
<template x-for="week in cal.weeks" :key="week[0].date.toISOString()"> <div class="calendar-week"> <template x-for="day in week" :key="day.date.toISOString()"> <button type="button" :disabled="day.isDisabled" :class="{ 'is-outside': !day.isCurrentMonth, 'is-today': day.isToday, 'is-selected': day.isSelected }" @click="cal.select(day.date)" x-text="cal.format(day.date, 'd')" ></button> </template> </div> </template>
<p x-show="cal.selected"> Selected: <strong x-text="cal.format(cal.selected, 'PPP')"></strong> </p></div>Range picker with bounds
<div x-data="{ cal: $calendar({ mode: 'range', weekStartsOn: 1, minDate: new Date(2024, 0, 1), maxDate: new Date(2024, 11, 31) }) }"> <!-- same grid markup as above, plus range classes --> <template x-for="week in cal.weeks" :key="week[0].date.toISOString()"> <div class="calendar-week"> <template x-for="day in week" :key="day.date.toISOString()"> <button type="button" :disabled="day.isDisabled" :class="{ 'is-range-start': day.isRangeStart, 'is-range-end': day.isRangeEnd, 'is-in-range': day.isInRange }" @click="cal.select(day.date)" x-text="cal.format(day.date, 'd')" ></button> </template> </div> </template></div>Two-month range picker
Use numberOfMonths: 2 and loop cal.months for side-by-side grids (react-day-picker style). Global prev/next arrows move the anchor by two months.
<div x-data="{ cal: $calendar({ mode: 'range', weekStartsOn: 1, numberOfMonths: 2, month: new Date(2024, 0, 1) }) }"> <div class="calendar-header"> <button type="button" @click="cal.prevMonth()">Previous</button> <button type="button" @click="cal.nextMonth()">Next</button> </div>
<div class="calendar-months"> <template x-for="monthView in cal.months" :key="monthView.month.toISOString()"> <div class="calendar-month"> <strong x-text="cal.formatMonth(monthView.month)"></strong>
<div class="calendar-weekdays"> <template x-for="label in cal.weekdayLabels" :key="label"> <span x-text="label"></span> </template> </div>
<template x-for="week in monthView.weeks" :key="week[0].date.toISOString()"> <div class="calendar-week"> <template x-for="day in week" :key="day.date.toISOString()"> <button type="button" :disabled="day.isDisabled" :class="{ 'is-outside': !day.isCurrentMonth, 'is-range-start': day.isRangeStart, 'is-range-end': day.isRangeEnd, 'is-in-range': day.isInRange }" @click="cal.select(day.date)" x-text="cal.format(day.date, 'd')" ></button> </template> </div> </template> </div> </template> </div></div>Spanish locale
import { es } from "date-fns/locale";
Alpine.data("bookingCalendar", () => ({ cal: null, init() { this.cal = this.$calendar({ locale: es, weekStartsOn: 1 }); },}));Tree-shaking
Import helpers directly when you do not need the Alpine plugin:
import { createCalendar } from "@ailuracode/alpine-calendar";
const cal = createCalendar({ weekStartsOn: 1 });Headless controller (no Alpine)
CalendarController extends BaseController and can be used independently of Alpine:
import { CalendarController } from "@ailuracode/alpine-calendar";
const controller = new CalendarController({ mode: "range", weekStartsOn: 1, minDate: new Date(2024, 0, 1),});
// Navigationcontroller.nextMonth();controller.prevMonth();controller.goToToday();
// Selectioncontroller.select(new Date());controller.clear();
// Typed eventscontroller.on("select", (date) => console.log("Selected:", date));controller.on("monthChange", ({ month }) => console.log("Month:", month));controller.on("clear", () => console.log("Cleared"));
// Cleanupcontroller.destroy();Factory functions
| Function | Returns | Description |
|---|---|---|
createCalendar(options?) |
CalendarInstance |
Backward-compatible store-shaped object |
createCalendarMagic() |
CalendarMagic |
$calendar magic function |
new CalendarController(options?) |
CalendarController |
Full controller with events and destroy() |
Events
| Event | Detail | When |
|---|---|---|
select |
Date |
After selection changes |
monthChange |
{ month: Date } |
After navigation — month is the anchor (first visible month) |
clear |
undefined |
After selection is cleared |
toStore()
For backward compatibility, CalendarController.toStore() returns an object matching the CalendarInstance interface. All getters delegate to the controller — mutations flow through commands and trigger events.
SSR
The plugin does not touch window or navigator during initialization. Calendar instances only use Date and date-fns, so they are safe to create during SSR as long as you avoid rendering browser-only UI around them.
License
MIT
