A lightweight, AlpineJS-native calendar component with inline/popup display, input binding with masking, single/multiple/range selection, month/year pickers, birth-date wizard, CSS custom property theming, and timezone-safe date handling.
pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendarimportAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()<linkrel="stylesheet" href="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.css"><scriptdefersrc="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script><scriptsrc="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.cdn.js"></script>The CDN build auto-registers via alpine:init — no manual setup needed. Works with Livewire, Statamic, or any server-rendered HTML.
You can use x-data in any block element to load the calendar.
<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div><divx-data="calendar({ mode: 'single', display: 'popup' })"><inputx-ref="rc-input" type="text" class="rc-input"></div>Provide your own <input> with x-ref="rc-input" — the calendar binds to it automatically, attaching focus/blur handlers, input masking, and ARIA attributes. The popup overlay with close button, transitions, and mobile-responsive sizing is auto-rendered alongside the input.
To use a custom ref name:
<divx-data="calendar({ display: 'popup', inputRef: 'dateField' })"><inputx-ref="dateField" type="text" class="my-custom-input"></div><divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div><divx-data="calendar({ mode: 'multiple' })"></div><divx-data="calendar({ mode: 'single', wizard: true })"></div>Wizard modes: true (or 'full') for Year → Month → Day, 'year-month' for Year → Month, 'month-day' for Month → Day.
Set precision: 'month' to turn the calendar into a forward-looking month picker. It opens directly on the months grid (with the year prev/next arrows), and clicking a month commits the first day of that month as the selection — there is no day grid and no separate year step.
<divx-data="calendar({ mode: 'single', precision: 'month', format: 'MMMM YYYY', display: 'popup', minDate: '2026-06-01', maxDate: '2027-12-31',})"><inputx-ref="rc-input" type="text" class="rc-input"></div>Behavior:
- Opens on the months grid, positioned with the usual precedence:
value>initialMonth> today. The opening month is clamped into[minDate, maxDate], so the picker never opens on an out-of-range year. minDate/maxDateare hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. WithminDate: '2026-06-01'andmaxDate: '2027-12-31', only 2026 and 2027 are reachable and months before June 2026 are disabled.- Clicking a month commits the 1st of that month, emits
calendar:change(withdetail.dates[0]= first-of-month, e.g.'2026-08-01'), formats the input viaformat(e.g.'MMMM YYYY'→"August 2026"), and closes the popup. - The bound input is made read-only (selection-only) — a
'MMMM YYYY'value can't be typed back in. - Set
nameto submit the value in a plain form — a hidden<input>carries the first-of-month ISO string (e.g.2026-08-01), no event wiring needed. formatdefaults to'MM/YYYY'when omitted. Use a month-only format such as'MM/YYYY'or'MMMM YYYY'; a day-based format logs a warning.- Designed for
mode: 'single'. Combining it withrange/multiple, or withwizard, logs a warning (precision: 'month'takes precedence over the wizard).
Restoring a selection across pages. Pass the stored first-of-month back as value (or the month as initialMonth):
<!-- Reopens on August 2026, with August marked selected and "August 2026" in the input --><divx-data="calendar({ mode: 'single', precision: 'month', format: 'MMMM YYYY', display: 'popup', minDate: '2026-06-01', maxDate: '2027-12-31', value: '2026-08-01',})"><inputx-ref="rc-input" type="text" class="rc-input"></div>value both selects and displays the month; initialMonth ('2026-08') only positions the view without selecting.
<form><divx-data="calendar({ mode: 'single', name: 'date' })"></div><buttontype="submit">Submit</button></form>When name is set, hidden <input> elements are auto-generated for form submission.
Set template: false to require a manual template, or provide your own .rc-calendar element — the calendar skips auto-rendering when it detects an existing .rc-calendar:
<!-- Manual template (auto-rendering skipped) --><divx-data="calendar({ mode: 'single' })"><divclass="rc-calendar" @keydown="handleKeydown($event)" tabindex="0" role="application"><!-- your custom template here --></div></div><!-- Explicitly disabled --><divx-data="calendar({ mode: 'single', template: false })"></div>Set value in the config to pre-select dates on load:
<!-- Single date --><divx-data="calendar({ mode: 'single', value: '2026-03-15' })"></div><!-- Range --><divx-data="calendar({ mode: 'range', value: '2026-03-10 - 2026-03-20' })"></div><!-- Multiple dates --><divx-data="calendar({ mode: 'multiple', value: '2026-03-10, 2026-03-15, 2026-03-20' })"></div>Use setValue() to change the selection after initialization:
<divx-data="calendar({ mode: 'single' })" x-ref="cal"><button@click="$refs.cal.setValue('2026-06-15')">Set June 15</button><button@click="$refs.cal.clear()">Clear</button></div>Pass backend variables directly into the config:
<divx-data="calendar({ mode: 'single', value: '{{ $date }}' })"></div>Or with Livewire's @entangle:
<divx-data="calendar({ mode: 'single', value: @entangle('date') })"></div>All options are passed via x-data="calendar({ ... })".
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'single' | 'multiple' | 'range' | 'single' | Selection mode |
precision | 'day' | 'month' | 'day' | Selection granularity. 'month' is a month picker (see Month Picker) |
display | 'inline' | 'popup' | 'inline' | Inline calendar or popup with input |
format | string | 'DD/MM/YYYY' | Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM) |
months | number | 1 | Months to display (1=single, 2=dual side-by-side, 3+=scrollable) |
mobileMonths | number | — | Months to show on mobile (<640px). Works with any months value; the layout automatically switches between side-by-side and scrollable when the two counts cross the 3-month threshold. |
firstDay | 0–6 | 1 | First day of week (0=Sun, 1=Mon, ...) |
mask | boolean | true | Enable input masking |
value | string | — | Initial value (ISO or formatted string) |
name | string | '' | Input name attribute for form submission |
locale | string | — | BCP 47 locale for month/day names |
timezone | string | — | IANA timezone for resolving "today" |
closeOnSelect | boolean | true | Close popup after selection |
allowDeselect | boolean | true | When false, re-clicking a selected date keeps the selection instead of toggling it off (single/multiple modes; the popup still closes as if the date was picked). Programmatic clearing (clearSelection(), setValue()) is unaffected |
wizard | boolean | 'year-month' | 'month-day' | false | Birth date wizard mode |
beforeSelect | (date, ctx) => boolean | — | Custom validation before selection |
showWeekNumbers | boolean | false | Show ISO 8601 week numbers alongside the day grid |
inputId | string | — | ID for the popup input (allows external <label for="...">) |
inputRef | string | 'rc-input' | Alpine x-ref name for the input element |
initialMonth | string | — | Month to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set |
scrollHeight | number | 400 | Max height (px) of scrollable container when months >= 3 |
presets | RangePreset[] | — | Predefined date range shortcuts (see Range Presets) |
constraintMessages | ConstraintMessages | — | Custom tooltip strings for disabled dates |
dateMetadata | DateMetaProvider | — | Per-date metadata: labels, availability, colors (see Date Metadata) |
template | boolean | true | Auto-render template when no .rc-calendar exists |
| Token | Output | Example |
|---|---|---|
YYYY | 4-digit year | 2026 |
YY | 2-digit year | 26 |
MMMM | Full month name (locale-aware) | March |
MMM | Short month name (locale-aware) | Mar |
MM | Month, zero-padded | 03 |
M | Month | 3 |
DD | Day, zero-padded | 05 |
D | Day | 5 |
Month-name tokens (MMM, MMMM) use the locale config option for localization. Input masking is automatically disabled when the format contains month-name tokens since they produce variable-length strings.
<!-- Human-readable date input --><divx-data="calendar({ format: 'DD MMM YYYY', locale: 'en-US', display: 'popup' })"><inputx-ref="rc-input" type="text" placeholder="15 Mar 2026" /></div>| Option | Type | Description |
|---|---|---|
minDate | string | Earliest selectable date (ISO) |
maxDate | string | Latest selectable date (ISO) |
disabledDates | string[] | Specific dates to disable (ISO) |
disabledDaysOfWeek | number[] | Days of week to disable (0=Sun, 6=Sat) |
enabledDates | string[] | Force-enable specific dates (overrides day-of-week rules) |
enabledDaysOfWeek | number[] | Only these days are selectable |
disabledMonths | number[] | Months to disable (1=Jan, 12=Dec) |
enabledMonths | number[] | Only these months are selectable |
disabledYears | number[] | Specific years to disable |
enabledYears | number[] | Only these years are selectable |
minRange | number | Minimum range length in days (inclusive) |
maxRange | number | Maximum range length in days (inclusive) |
rules | CalendarConfigRule[] | Period-specific constraint overrides |
Templating-engine interop: every array option above except
rulesalso accepts a JSON-encoded string (e.g.disabledDaysOfWeek="[0,6]"). Useful when the value comes from Blade, Twig, or any engine that stringifies arrays into HTML attributes.rulesmust be passed as a real array in JS config. Malformed input is logged viaconsole.warnand silently ignored.
Override constraints for specific date ranges. First matching rule wins; unmatched dates use global constraints.
<divx-data="calendar({ mode: 'range', minRange: 3, rules: [ { from: '2025-06-01', to: '2025-08-31', minRange: 7, disabledDaysOfWeek: [0, 6] } ]})">These properties are available in templates via Alpine's reactivity:
| Property | Type | Description |
|---|---|---|
mode | string | Current selection mode |
display | string | 'inline' or 'popup' |
month | number | Currently viewed month (1–12) |
year | number | Currently viewed year |
view | string | Current view: 'days', 'months', or 'years' |
isOpen | boolean | Whether popup is open |
grid | MonthGrid[] | Day grid data for rendering |
monthGrid | MonthCell[][] | Month picker grid |
yearGrid | YearCell[][] | Year picker grid |
inputValue | string | Formatted selected value |
focusedDate | CalendarDate | null | Keyboard-focused date |
hoverDate | CalendarDate | null | Mouse-hovered date (for range preview) |
wizardStep | number | Current wizard step (0=off, 1–3) |
showWeekNumbers | boolean | Whether week numbers are displayed |
presets | RangePreset[] | Configured range presets |
isScrollable | boolean | Whether the calendar uses scrollable layout (months >= 3) |
| Getter | Type | Description |
|---|---|---|
selectedDates | CalendarDate[] | Array of selected dates |
formattedValue | string | Formatted display string |
hiddenInputValues | string[] | ISO strings for hidden form inputs |
focusedDateISO | string | ISO string of focused date (for aria-activedescendant) |
weekdayHeaders | string[] | Localized weekday abbreviations |
yearLabel | string | Current year as string |
decadeLabel | string | Decade range label (e.g., "2024 – 2035") |
wizardStepLabel | string | Current wizard step name |
canGoPrev | boolean | Whether backward navigation is possible (skips fully-unavailable months, stops at minDate) |
canGoNext | boolean | Whether forward navigation is possible (skips fully-unavailable months, stops at maxDate) |
| Method | Description |
|---|---|
prev() | Navigate to previous month/year/decade |
next() | Navigate to next month/year/decade |
goToToday() | Jump to current month |
goTo(year, month?) | Navigate to specific year/month |
setView(view) | Switch to 'days', 'months', or 'years' |
| Method | Description |
|---|---|
selectDate(date) | Select or toggle a date |
selectMonth(month) | Select month in month picker |
selectYear(year) | Select year in year picker |
clearSelection() | Clear all selected dates |
isSelected(date) | Check if date is selected |
isInRange(date, hover?) | Check if date is within range |
isRangeStart(date) | Check if date is range start |
isRangeEnd(date) | Check if date is range end |
applyPreset(index) | Apply a range preset by index |
Access these via $refs:
<divx-data="calendar({ ... })" x-ref="cal"><button@click="$refs.cal.setValue('2025-06-15')">Set Date</button><button@click="$refs.cal.clear()">Clear</button></div>| Method | Description |
|---|---|
setValue(value) | Set selection (ISO string, string[], or CalendarDate) |
clear() | Clear selection |
goTo(year, month) | Navigate without changing selection |
open() / close() / toggle() | Popup lifecycle |
getSelection() | Get current selection as CalendarDate[] |
updateConstraints(options) | Update constraints at runtime |
updateDateMetadata(provider) | Replace metadata at runtime (static map, callback, or null to clear) |
| Method | Description |
|---|---|
dayClasses(cell) | CSS class object for day cells |
dayMeta(cell) | Get DateMeta for a day cell (label, availability, color, cssClass) |
dayStyle(cell) | Inline style string for metadata color (--color-calendar-day-meta) |
monthClasses(cell) | CSS class object for month cells |
yearClasses(cell) | CSS class object for year cells |
monthYearLabel(index) | Formatted "Month Year" label for grid at index |
handleKeydown(event) | Keyboard navigation handler |
handleFocus() | Input focus handler (opens popup) |
handleBlur() | Input blur handler (parses typed value) |
| Method | Description |
|---|---|
bindInput(el) | Manually bind to an input element |
handleInput(event) | For unbound inputs using :value + @input |
Listen with Alpine's @ syntax on the calendar container:
<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">| Event | Detail | Description |
|---|---|---|
calendar:change | { value, dates, formatted } | Selection settled on a committed value |
calendar:select | { value, dates, formatted } | Selection updated (every click, including partial range) |
calendar:navigate | { year, month, view } | Month/year navigation |
calendar:open | — | Popup opened |
calendar:close | — | Popup closed |
calendar:view-change | { view, year, month } | View switched (days/months/years) |
calendar:change fires only when the selection is in a committed state —
the value is final and safe to persist, submit, or send to analytics:
- Single mode — every toggle (one click commits the value)
- Multiple mode — every add/remove (each click commits a new set)
- Range mode — only when both endpoints are chosen (or the range is cleared).
The first click of a two-click range does not fire
calendar:change.
calendar:select fires on every selection update, including the partial
first-click of a range. Use it for UIs that need per-click feedback (live
previews, step counters). If you previously listened for calendar:change in
range mode and want the old per-click behavior, switch to calendar:select.
In display: 'popup' mode, when the component auto-renders its template, the
calendar overlay is teleported to document.body to escape CSS containing-block
issues (transforms, overflow: hidden, etc.). In that auto-rendered case, the
overlay is marked with a data-rc-portal attribute.
If you provide your own .rc-calendar markup or use template: false, the
library does not teleport or tag the overlay automatically — the portal
behavior applies only to the library-managed popup template.
If your application has a document-level outside-click handler (for drawers, dropdowns, modals), treat clicks inside the portal as "inside" by whitelisting that attribute (when using the auto-rendered popup overlay):
document.addEventListener('click',(e)=>{if(e.target.closest('[data-rc-portal]'))return// click came from the calendarif(!e.target.closest('.my-drawer'))closeDrawer()})The library intentionally does not call stopPropagation on overlay
clicks — that would silently break analytics click tracking and other
legitimate document-level listeners.
| Key | Action |
|---|---|
| Arrow keys | Move focus between days |
| Enter / Space | Select focused day |
| Page Down / Up | Next / previous month (hops over fully-unavailable months, like the arrows) |
| Shift + Page Down / Up | Next / previous year |
| Home / End | First / last day of month |
| Escape | Close popup or return to day view |
The calendar uses CSS custom properties for all visual styles. Override them in your CSS:
:root {
--color-calendar-primary:#4f46e5;
--color-calendar-primary-text:#ffffff;
--color-calendar-bg:#ffffff;
--color-calendar-text:#111827;
--color-calendar-hover:#f3f4f6;
--color-calendar-range:#eef2ff;
--color-calendar-today-ring:#818cf8;
--color-calendar-disabled:#d1d5db;
--color-calendar-border:#e5e7eb;
--color-calendar-other-month:#9ca3af;
--color-calendar-weekday:#6b7280;
--color-calendar-focus-ring:#4f46e5;
--color-calendar-overlay:rgba(0,0,0,0.2);
--radius-calendar:0.5rem;
--radius-calendar-day:9999px; /* day cell shape (default: pill) */--radius-calendar-day-range-edge:var(--radius-calendar-day); /* outer corners of range start/end */--radius-calendar-day-range-middle:0; /* in-range cells between endpoints */--shadow-calendar:010px15px-3pxrgb(000/0.1);
--font-calendar: system-ui, -apple-system, sans-serif;
}Defaults are declared inside :where(:root) (zero specificity), so any consumer override wins regardless of stylesheet load order — no !important needed.
Switch between pill, rounded square, and sharp by overriding the three day-radius variables together:
/* Rounded square */:root {
--radius-calendar-day:6px;
--radius-calendar-day-range-edge:6px;
--radius-calendar-day-range-middle:0;
}
/* Sharp / square */:root {
--radius-calendar-day:0;
--radius-calendar-day-range-edge:0;
--radius-calendar-day-range-middle:0;
}All classes use the .rc- prefix:
| Class | Description |
|---|---|
.rc-calendar | Root container |
.rc-header / .rc-header__nav / .rc-header__label | Navigation header |
.rc-weekdays / .rc-weekday | Weekday header row |
.rc-grid | Day grid container |
.rc-day | Day cell |
.rc-day--today | Today's date |
.rc-day--selected | Selected date |
.rc-day--range-start / .rc-day--range-end | Range endpoints |
.rc-day--in-range | Dates within range |
.rc-day--disabled | Disabled date |
.rc-day--other-month | Leading/trailing days |
.rc-day--focused | Keyboard-focused date |
.rc-day--available / .rc-day--unavailable | Metadata availability states |
.rc-day--has-label | Day cell with a metadata label |
.rc-day__number / .rc-day__label / .rc-day__dot | Day cell inner elements (number, label text, availability dot) |
.rc-month-grid / .rc-month | Month picker |
.rc-year-grid / .rc-year | Year picker |
.rc-months--dual | Two-month side-by-side layout |
.rc-nav--dual-hidden | Hidden nav arrow in dual-month layout (prev on 2nd month) |
.rc-nav--dual-next-first | Next arrow on 1st month (hidden on desktop, visible on mobile) |
.rc-nav--dual-next-last | Next arrow on 2nd month (visible on desktop, hidden on mobile) |
.rc-popup-overlay | Popup backdrop |
.rc-popup-header / .rc-popup-header__close | Popup close header bar |
.rc-calendar__header / .rc-calendar__footer | Consumer-provided header/footer slot containers |
.rc-calendar--wizard | Wizard mode container |
.rc-row--week-numbers / .rc-week-number | Week number row and cell |
.rc-grid--week-numbers | Grid with week number column |
.rc-presets / .rc-preset | Range preset container and buttons |
.rc-months--scroll | Scrollable multi-month container |
.rc-header--scroll-sticky | Sticky header in scrollable layout |
.rc-sr-only | Screen reader only utility |
The popup display mode is teleported to document.body after render so it can escape ancestors with overflow: hidden, transforms, or fixed-position containing blocks. That escape has two implications worth knowing.
All theme tokens are declared inside :where(:root) (zero specificity). Any consumer override wins at any stylesheet load order — even when the consumer's CSS bundle is loaded beforealpine-calendar.css. No !important required.
Use the data-rc-theme attribute on the x-data root. The library forwards it to the teleported overlay so your scoped CSS keeps working inside the portal:
<divx-data="calendar({ display: 'popup' })" data-rc-theme="black"><inputx-ref="rc-input" type="text"></div>/* Works for both inline calendars (descendant selector) and portal popups (the overlay carries data-rc-theme after teleport). */
[data-rc-theme="black"] {
--color-calendar-primary:#111827;
--color-calendar-primary-text:#ffffff;
}
[data-rc-theme="black"] .rc-day--selected {
background:#000;
}A common pitfall: a wrapper class like .calendar-black .rc-day--selected { … } works for inline calendars but stops working in popup mode because the overlay leaves the wrapper's subtree. data-rc-theme is the supported way to solve this — it survives the teleport.
The library re-syncs data-rc-theme on every open() call, so toggling the attribute reactively (e.g. via :data-rc-theme="theme") takes effect on the next open.
Place arbitrary HTML inside the calendar — including inside the teleported popup — using <template data-rc-slot="…">:
<divx-data="calendar({ display: 'popup', mode: 'range' })"><inputx-ref="rc-input" type="text"><templatedata-rc-slot="header"><p>Charters operate Saturday to Saturday.</p></template><templatedata-rc-slot="footer"><p>Need help? <ahref="/contact">Contact us</a>.</p></template></div>Slot HTML is rendered inside .rc-calendar__header / .rc-calendar__footer containers. Header sits above the day grid (below the popup close button); footer sits below the grid and any presets. Slot content shares Alpine scope with the calendar — x-show, x-text, and bindings work normally and survive the teleport.
If both slot tags are present, the first occurrence per name wins; whitespace-only templates are ignored.
Every .rc-* class and every --color-calendar-* / --radius-calendar-* variable applies inside the popup. The only thing that doesn't traverse is ancestor selectors that no longer match after teleport — that's the case data-rc-theme solves.
Set defaults that apply to every calendar instance:
import{calendarPlugin}from'@reachweb/alpine-calendar'calendarPlugin.defaults({firstDay: 1,locale: 'el'})Alpine.plugin(calendarPlugin)Instance config overrides global defaults.
Display ISO 8601 week numbers alongside the day grid:
<divx-data="calendar({ mode: 'single', showWeekNumbers: true, firstDay: 1 })"></div>Week numbers appear in a narrow column to the left of each row.
Add quick-select buttons for common date ranges. Works with range and single modes:
<divx-data="calendar({ mode: 'range', presets: [ presetToday(), presetLastNDays(7), presetThisWeek(), presetThisMonth(), presetLastMonth() ]})"></div>Import the built-in factories:
import{presetToday,presetYesterday,presetLastNDays,presetThisWeek,presetLastWeek,presetThisMonth,presetLastMonth,presetThisYear,presetLastYear,}from'@reachweb/alpine-calendar'All factories accept an optional label and timezone parameter. presetThisWeek and presetLastWeek also accept a firstDay (default: 1 = Monday).
Custom presets:
constcustomPreset={label: 'Next 30 Days',value: ()=>{consttoday=CalendarDate.today()return[today,today.addDays(29)]}}Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.
Pass an object keyed by ISO date strings:
<divx-data="calendar({ mode: 'single', dateMetadata: { '2026-03-01': { label: '$120', availability: 'available' }, '2026-03-05': { label: '$180', availability: 'available', color: '#ea580c' }, '2026-03-06': { availability: 'unavailable' }, '2026-03-07': { label: 'Sold', availability: 'unavailable' }, }})"></div>Use a function for computed metadata. Called for each visible date:
<divx-data="calendar({ mode: 'range', dateMetadata: (date) => { const d = date.toNativeDate().getDay() if (d === 0 || d === 6) return { availability: 'unavailable' } return { label: '$' + (100 + date.day * 3), availability: 'available' } }})"></div>| Property | Type | Description |
|---|---|---|
label | string | Text below the day number (e.g., price, event name) |
availability | 'available' | 'unavailable' | 'available' shows a green dot, 'unavailable' disables selection with strikethrough |
color | string | CSS color for the label and dot (e.g., '#16a34a') |
cssClass | string | Custom CSS class(es) added to the day cell |
All properties are optional and work independently. Dates with availability: 'unavailable' cannot be selected regardless of constraint settings.
When a whole month is unavailable (e.g. a booking calendar with departures only in certain months), the day-view prev/next arrows — and the Page Up/Page Down keys — hop over the empty months to the nearest month that still has a selectable day, rather than dead-ending at the gap. Navigation still hard-stops at minDate/maxDate.
Replace metadata after initialization with updateDateMetadata():
// Update with new data (e.g., after fetching availability)$refs.cal.updateDateMetadata({'2026-03-15': {label: '$200',availability: 'available'},'2026-03-20': {availability: 'unavailable'},})// Clear all metadata$refs.cal.updateDateMetadata(null)When months is 3 or more, the calendar renders as a vertically scrollable container instead of side-by-side panels:
<divx-data="calendar({ mode: 'range', months: 6 })"></div><!-- Custom scroll height --><divx-data="calendar({ mode: 'range', months: 12, scrollHeight: 500 })"></div>A sticky header tracks the currently visible month as you scroll. Default scroll height is 400px.
- Mobile (<640px): Popup renders as a centered fullscreen overlay. Touch-friendly targets (min 44px).
- Desktop (>=640px): Popup renders as a centered modal with scale-in animation.
- Two months: Side-by-side on desktop, stacked on mobile. Both nav arrows appear on the top month when stacked.
mobileMonths: Show a different number of months on mobile — fewer (e.g.,mobileMonths: 1withmonths: 2) or more (e.g.,mobileMonths: 12withmonths: 2for a booking calendar that's compact on desktop and long-scroll on mobile).- Scrollable (3+ months): Smooth scroll with
-webkit-overflow-scrolling: touch. prefers-reduced-motion: All animations are disabled.
mobileMonths lets you pick a different month count for narrow viewports (<640px). It works with any months value and can be smaller or larger than the desktop count — the layout automatically switches between single, side-by-side, and scrollable as needed.
<!-- Simplify on mobile: 2 months on desktop, 1 month on mobile --><divx-data="calendar({ mode: 'range', months: 2, mobileMonths: 1 })"></div><!-- Booking calendar: 2 months side-by-side on desktop, 12-month scrollable list on mobile --><divx-data="calendar({ mode: 'range', months: 2, mobileMonths: 12 })"></div><!-- Single month on desktop, 3-month scrollable on mobile --><divx-data="calendar({ mode: 'single', months: 1, mobileMonths: 3 })"></div>The calendar listens for viewport changes at the 640px breakpoint and swaps layouts live — even when the new count crosses the 3-month scrollable threshold. Selection and visible month are preserved across the switch. Focus is preserved when the previously focused date remains in the rendered grid; otherwise it is cleared and reinitialized on the next keyboard navigation.
The calendar targets WCAG 2.1 AA compliance:
- Full keyboard navigation (arrow keys, Enter, Escape, Page Up/Down, Home/End)
- ARIA roles:
application,dialog,combobox,option,group aria-live="polite"announcements for navigation and selection changesaria-activedescendantfor focus management within the gridaria-modal="true"on popup overlaysaria-expanded,aria-selected,aria-disabledon interactive elements:focus-visibleoutlines on all interactive elements- Screen reader support via
.rc-sr-onlyutility class - Validated with axe-core (no critical or serious violations)
| File | Format | Size (gzip) | Use case |
|---|---|---|---|
alpine-calendar.es.js | ESM | ~19KB | Bundler (import) |
alpine-calendar.umd.js | UMD | ~12KB | Legacy (require()) |
alpine-calendar.cdn.js | IIFE | ~12KB | CDN / <script> tag |
alpine-calendar.css | CSS | ~4KB | All environments |
Full type definitions are included. Key exports:
import{calendarPlugin,CalendarDate,getISOWeekNumber,SingleSelection,MultipleSelection,RangeSelection,createCalendarData,parseDate,formatDate,createMask,computePosition,autoUpdate,generateMonth,generateMonths,generateMonthGrid,generateYearGrid,createDateConstraint,createRangeValidator,createDisabledReasons,isDateDisabled,normalizeDateMeta,presetToday,presetYesterday,presetLastNDays,presetThisWeek,presetLastWeek,presetThisMonth,presetLastMonth,presetThisYear,presetLastYear,}from'@reachweb/alpine-calendar'importtype{CalendarConfig,CalendarConfigRule,RangePreset,DayCell,MonthCell,YearCell,Selection,Placement,PositionOptions,DateConstraintOptions,DateConstraintProperties,DateConstraintRule,ConstraintMessages,DateMeta,DateMetaProvider,InputMask,MaskEventHandlers,}from'@reachweb/alpine-calendar'@push('styles')
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.css">
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.cdn.js"></script>
@endpushUse wire:ignore on the calendar container to prevent Livewire from morphing it:
<divwire:ignore><divx-data="calendar({ mode: 'single', display: 'popup' })"
@calendar:change="$wire.set('date', $event.detail.value)"><inputx-ref="rc-input" type="text" class="rc-input"></div></div>pnpm install # Install dependencies
pnpm dev # Start dev server with demo
pnpm test# Run tests
pnpm test:watch # Run tests in watch mode
pnpm test:coverage # Run tests with coverage report
pnpm typecheck # Type-check without emitting
pnpm lint # Lint source files
pnpm lint:fix # Lint and auto-fix
pnpm format # Format source files with Prettier
pnpm build # Build all bundles (ESM + UMD + CDN + CSS + types)
pnpm build:lib # Build ESM + UMD only
pnpm build:cdn # Build CDN/IIFE bundle onlyBefore a release, run the full verification chain:
pnpm typecheck && pnpm lint && pnpm test&& pnpm buildMIT