Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Alpine Calendar

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.

Live Demo

Installation

npm / pnpm

pnpm add @reachweb/alpine-calendar
# or
npm install @reachweb/alpine-calendar
importAlpinefrom'alpinejs'import{calendarPlugin}from'@reachweb/alpine-calendar'import'@reachweb/alpine-calendar/css'Alpine.plugin(calendarPlugin)Alpine.start()

CDN (no bundler)

<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.

Quick Start

You can use x-data in any block element to load the calendar.

Inline Single Date

<divx-data="calendar({ mode: 'single', firstDay: 1 })"></div>

Popup with Input

<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>

Range Selection (2-Month)

<divx-data="calendar({ mode: 'range', months: 2, firstDay: 1 })"></div>

Multiple Date Selection

<divx-data="calendar({ mode: 'multiple' })"></div>

Birth Date Wizard

<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.

Month Picker (departure-month)

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/maxDate are hard limits. The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With minDate: '2026-06-01' and maxDate: '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 (with detail.dates[0] = first-of-month, e.g. '2026-08-01'), formats the input via format (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 name to 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.
  • format defaults 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 with range/multiple, or with wizard, 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 Submission

<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.

Disabling Auto-Rendering

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>

Presetting Values

Initial Value

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>

Dynamic Updates

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>

Server-Rendered / Livewire

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>

Configuration

All options are passed via x-data="calendar({ ... })".

OptionTypeDefaultDescription
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
formatstring'DD/MM/YYYY'Date format (tokens: DD, MM, YYYY, D, M, YY, MMM, MMMM)
monthsnumber1Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonthsnumberMonths 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.
firstDay0–61First day of week (0=Sun, 1=Mon, ...)
maskbooleantrueEnable input masking
valuestringInitial value (ISO or formatted string)
namestring''Input name attribute for form submission
localestringBCP 47 locale for month/day names
timezonestringIANA timezone for resolving "today"
closeOnSelectbooleantrueClose popup after selection
allowDeselectbooleantrueWhen 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
wizardboolean | 'year-month' | 'month-day'falseBirth date wizard mode
beforeSelect(date, ctx) => booleanCustom validation before selection
showWeekNumbersbooleanfalseShow ISO 8601 week numbers alongside the day grid
inputIdstringID for the popup input (allows external <label for="...">)
inputRefstring'rc-input'Alpine x-ref name for the input element
initialMonthstringMonth to display on first paint ('YYYY-MM' or 'YYYY-MM-DD'); selection wins if also set
scrollHeightnumber400Max height (px) of scrollable container when months >= 3
presetsRangePreset[]Predefined date range shortcuts (see Range Presets)
constraintMessagesConstraintMessagesCustom tooltip strings for disabled dates
dateMetadataDateMetaProviderPer-date metadata: labels, availability, colors (see Date Metadata)
templatebooleantrueAuto-render template when no .rc-calendar exists

Format Tokens

TokenOutputExample
YYYY4-digit year2026
YY2-digit year26
MMMMFull month name (locale-aware)March
MMMShort month name (locale-aware)Mar
MMMonth, zero-padded03
MMonth3
DDDay, zero-padded05
DDay5

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>

Date Constraints

OptionTypeDescription
minDatestringEarliest selectable date (ISO)
maxDatestringLatest selectable date (ISO)
disabledDatesstring[]Specific dates to disable (ISO)
disabledDaysOfWeeknumber[]Days of week to disable (0=Sun, 6=Sat)
enabledDatesstring[]Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeeknumber[]Only these days are selectable
disabledMonthsnumber[]Months to disable (1=Jan, 12=Dec)
enabledMonthsnumber[]Only these months are selectable
disabledYearsnumber[]Specific years to disable
enabledYearsnumber[]Only these years are selectable
minRangenumberMinimum range length in days (inclusive)
maxRangenumberMaximum range length in days (inclusive)
rulesCalendarConfigRule[]Period-specific constraint overrides

Templating-engine interop: every array option above except rules also 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. rules must be passed as a real array in JS config. Malformed input is logged via console.warn and silently ignored.

Period-Specific Rules

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] } ]})">

Reactive State

These properties are available in templates via Alpine's reactivity:

PropertyTypeDescription
modestringCurrent selection mode
displaystring'inline' or 'popup'
monthnumberCurrently viewed month (1–12)
yearnumberCurrently viewed year
viewstringCurrent view: 'days', 'months', or 'years'
isOpenbooleanWhether popup is open
gridMonthGrid[]Day grid data for rendering
monthGridMonthCell[][]Month picker grid
yearGridYearCell[][]Year picker grid
inputValuestringFormatted selected value
focusedDateCalendarDate | nullKeyboard-focused date
hoverDateCalendarDate | nullMouse-hovered date (for range preview)
wizardStepnumberCurrent wizard step (0=off, 1–3)
showWeekNumbersbooleanWhether week numbers are displayed
presetsRangePreset[]Configured range presets
isScrollablebooleanWhether the calendar uses scrollable layout (months >= 3)

Computed Getters

GetterTypeDescription
selectedDatesCalendarDate[]Array of selected dates
formattedValuestringFormatted display string
hiddenInputValuesstring[]ISO strings for hidden form inputs
focusedDateISOstringISO string of focused date (for aria-activedescendant)
weekdayHeadersstring[]Localized weekday abbreviations
yearLabelstringCurrent year as string
decadeLabelstringDecade range label (e.g., "2024 – 2035")
wizardStepLabelstringCurrent wizard step name
canGoPrevbooleanWhether backward navigation is possible (skips fully-unavailable months, stops at minDate)
canGoNextbooleanWhether forward navigation is possible (skips fully-unavailable months, stops at maxDate)

Methods

Navigation

MethodDescription
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'

Selection

MethodDescription
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

Programmatic Control

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>
MethodDescription
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)

Template Helpers

MethodDescription
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)

Input Binding

MethodDescription
bindInput(el)Manually bind to an input element
handleInput(event)For unbound inputs using :value + @input

Events

Listen with Alpine's @ syntax on the calendar container:

<divx-data="calendar({ ... })"
@calendar:change="console.log($event.detail)"
@calendar:navigate="console.log($event.detail)">
EventDetailDescription
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:openPopup opened
calendar:closePopup closed
calendar:view-change{ view, year, month }View switched (days/months/years)

calendar:change vs calendar:select

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.

Popup Teleport and Outside-Click

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.

Keyboard Navigation

KeyAction
Arrow keysMove focus between days
Enter / SpaceSelect focused day
Page Down / UpNext / previous month (hops over fully-unavailable months, like the arrows)
Shift + Page Down / UpNext / previous year
Home / EndFirst / last day of month
EscapeClose popup or return to day view

Theming

The calendar uses CSS custom properties for all visual styles. Override them in your CSS:

Override variables

: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.

Day-cell shape

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;
}

CSS Class Reference

All classes use the .rc- prefix:

ClassDescription
.rc-calendarRoot container
.rc-header / .rc-header__nav / .rc-header__labelNavigation header
.rc-weekdays / .rc-weekdayWeekday header row
.rc-gridDay grid container
.rc-dayDay cell
.rc-day--todayToday's date
.rc-day--selectedSelected date
.rc-day--range-start / .rc-day--range-endRange endpoints
.rc-day--in-rangeDates within range
.rc-day--disabledDisabled date
.rc-day--other-monthLeading/trailing days
.rc-day--focusedKeyboard-focused date
.rc-day--available / .rc-day--unavailableMetadata availability states
.rc-day--has-labelDay cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dotDay cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-monthMonth picker
.rc-year-grid / .rc-yearYear picker
.rc-months--dualTwo-month side-by-side layout
.rc-nav--dual-hiddenHidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-firstNext arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-lastNext arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlayPopup backdrop
.rc-popup-header / .rc-popup-header__closePopup close header bar
.rc-calendar__header / .rc-calendar__footerConsumer-provided header/footer slot containers
.rc-calendar--wizardWizard mode container
.rc-row--week-numbers / .rc-week-numberWeek number row and cell
.rc-grid--week-numbersGrid with week number column
.rc-presets / .rc-presetRange preset container and buttons
.rc-months--scrollScrollable multi-month container
.rc-header--scroll-stickySticky header in scrollable layout
.rc-sr-onlyScreen reader only utility

Theming & Portal

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.

Cascade-safe defaults

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.

Scoping styles to specific calendar instances

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.

Header / footer slots

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.

What works inside the portal

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.

Global Defaults

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.

Week Numbers

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.

Range Presets

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)]}}

Date Metadata

Attach labels, pricing, availability indicators, and custom colors to individual dates. Useful for booking calendars, event schedules, and pricing displays.

Static Map

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>

Dynamic Callback

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>

DateMeta Properties

PropertyTypeDescription
labelstringText below the day number (e.g., price, event name)
availability'available' | 'unavailable''available' shows a green dot, 'unavailable' disables selection with strikethrough
colorstringCSS color for the label and dot (e.g., '#16a34a')
cssClassstringCustom 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.

Runtime Updates

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)

Multi-Month Scrollable Layout

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.

Responsive Behavior

  • 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: 1 with months: 2) or more (e.g., mobileMonths: 12 with months: 2 for 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.

Mobile Months

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.

Accessibility

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 changes
  • aria-activedescendant for focus management within the grid
  • aria-modal="true" on popup overlays
  • aria-expanded, aria-selected, aria-disabled on interactive elements
  • :focus-visible outlines on all interactive elements
  • Screen reader support via .rc-sr-only utility class
  • Validated with axe-core (no critical or serious violations)

Bundle Outputs

FileFormatSize (gzip)Use case
alpine-calendar.es.jsESM~19KBBundler (import)
alpine-calendar.umd.jsUMD~12KBLegacy (require())
alpine-calendar.cdn.jsIIFE~12KBCDN / <script> tag
alpine-calendar.cssCSS~4KBAll environments

TypeScript

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'

Livewire Integration

@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>
@endpush

Use 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>

Development

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 only

Before a release, run the full verification chain:

pnpm typecheck && pnpm lint && pnpm test&& pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages