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-calendarimport Alpine from 'alpinejs'
import { calendarPlugin } from '@reachweb/alpine-calendar'
import '@reachweb/alpine-calendar/css'
Alpine.plugin(calendarPlugin)
Alpine.start()<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script src="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.
<div x-data="calendar({ mode: 'single', firstDay: 1 })"></div><div x-data="calendar({ mode: 'single', display: 'popup' })">
<input x-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:
<div x-data="calendar({ display: 'popup', inputRef: 'dateField' })">
<input x-ref="dateField" type="text" class="my-custom-input">
</div><div x-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div><div x-data="calendar({ mode: 'multiple' })"></div><div x-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.
<form>
<div x-data="calendar({ mode: 'single', name: 'date' })"></div>
<button type="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) -->
<div x-data="calendar({ mode: 'single' })">
<div class="rc-calendar" @keydown="handleKeydown($event)" tabindex="0" role="application">
<!-- your custom template here -->
</div>
</div>
<!-- Explicitly disabled -->
<div x-data="calendar({ mode: 'single', template: false })"></div>Set value in the config to pre-select dates on load:
<!-- Single date -->
<div x-data="calendar({ mode: 'single', value: '2026-03-15' })"></div>
<!-- Range -->
<div x-data="calendar({ mode: 'range', value: '2026-03-10 - 2026-03-20' })"></div>
<!-- Multiple dates -->
<div x-data="calendar({ mode: 'multiple', value: '2026-03-10, 2026-03-15, 2026-03-20' })"></div>Use setValue() to change the selection after initialization:
<div x-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:
<div x-data="calendar({ mode: 'single', value: '{{ $date }}' })"></div>Or with Livewire's @entangle:
<div x-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 |
display |
'inline' | 'popup' |
'inline' |
Inline calendar or popup with input |
format |
string |
'DD/MM/YYYY' |
Date format (tokens: DD, MM, YYYY, D, M, YY) |
months |
number |
1 |
Months to display (1=single, 2=dual side-by-side, 3+=scrollable) |
mobileMonths |
number |
— | Months to show on mobile (<640px). Only used when months is 2. |
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 |
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 |
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 |
| 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 |
Override constraints for specific date ranges. First matching rule wins; unmatched dates use global constraints.
<div x-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 |
canGoNext |
boolean |
Whether forward navigation is possible |
| 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:
<div x-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:
<div x-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">| Event | Detail | Description |
|---|---|---|
calendar:change |
{ value, dates, formatted } |
Selection changed |
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) |
| Key | Action |
|---|---|
| Arrow keys | Move focus between days |
| Enter / Space | Select focused day |
| Page Down / Up | Next / previous month |
| 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;
--shadow-calendar: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--font-calendar: system-ui, -apple-system, sans-serif;
}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--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 |
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:
<div x-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:
<div x-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:
const customPreset = {
label: 'Next 30 Days',
value: () => {
const today = 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:
<div x-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:
<div x-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.
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:
<div x-data="calendar({ mode: 'range', months: 6 })"></div>
<!-- Custom scroll height -->
<div x-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 fewer months on mobile (e.g.,mobileMonths: 1withmonths: 2displays a single month on narrow viewports).- Scrollable (3+ months): Smooth scroll with
-webkit-overflow-scrolling: touch. prefers-reduced-motion: All animations are disabled.
When using months: 2, the calendar shows two months side-by-side on desktop and stacks them vertically on mobile. Set mobileMonths: 1 to show only a single month on mobile instead:
<div x-data="calendar({ mode: 'range', months: 2, mobileMonths: 1 })"></div>The calendar listens for viewport changes at the 640px breakpoint and switches between the desktop and mobile month counts automatically. Selection is preserved across viewport changes.
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'
import type {
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:
<div wire:ignore>
<div x-data="calendar({ mode: 'single', display: 'popup' })"
@calendar:change="$wire.set('date', $event.detail.value)">
<input x-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