Skip to content

reachweb/alpine-calendar

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
import Alpine from 'alpinejs'
import { calendarPlugin } from '@reachweb/alpine-calendar'
import '@reachweb/alpine-calendar/css'

Alpine.plugin(calendarPlugin)
Alpine.start()

CDN (no bundler)

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@reachweb/alpine-calendar/dist/alpine-calendar.cdn.js"></script>

The CDN build auto-registers via alpine:init — no manual setup needed. Works with Livewire, Statamic, or any server-rendered HTML.

Quick Start

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

Inline Single Date

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

Popup with Input

<div x-data="calendar({ mode: 'single', display: 'popup' })">
  <input x-ref="rc-input" type="text" class="rc-input">
</div>

Provide your own <input> with x-ref="rc-input" — the calendar binds to it automatically, attaching focus/blur handlers, input masking, and ARIA attributes. The popup overlay with close button, transitions, and mobile-responsive sizing is auto-rendered alongside the input.

To use a custom ref name:

<div x-data="calendar({ display: 'popup', inputRef: 'dateField' })">
  <input x-ref="dateField" type="text" class="my-custom-input">
</div>

Range Selection (2-Month)

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

Multiple Date Selection

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

Birth Date Wizard

<div x-data="calendar({ mode: 'single', wizard: true })"></div>

Wizard modes: true (or 'full') for Year → Month → Day, 'year-month' for Year → Month, 'month-day' for Month → Day.

Form Submission

<form>
  <div x-data="calendar({ mode: 'single', name: 'date' })"></div>
  <button type="submit">Submit</button>
</form>

When name is set, hidden <input> elements are auto-generated for form submission.

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) -->
<div x-data="calendar({ mode: 'single' })">
  <div class="rc-calendar" @keydown="handleKeydown($event)" tabindex="0" role="application">
    <!-- your custom template here -->
  </div>
</div>

<!-- Explicitly disabled -->
<div x-data="calendar({ mode: 'single', template: false })"></div>

Presetting Values

Initial Value

Set value in the config to pre-select dates on load:

<!-- Single date -->
<div x-data="calendar({ mode: 'single', value: '2026-03-15' })"></div>

<!-- Range -->
<div x-data="calendar({ mode: 'range', value: '2026-03-10 - 2026-03-20' })"></div>

<!-- Multiple dates -->
<div x-data="calendar({ mode: 'multiple', value: '2026-03-10, 2026-03-15, 2026-03-20' })"></div>

Dynamic Updates

Use setValue() to change the selection after initialization:

<div x-data="calendar({ mode: 'single' })" x-ref="cal">
  <button @click="$refs.cal.setValue('2026-06-15')">Set June 15</button>
  <button @click="$refs.cal.clear()">Clear</button>
</div>

Server-Rendered / Livewire

Pass backend variables directly into the config:

<div x-data="calendar({ mode: 'single', value: '{{ $date }}' })"></div>

Or with Livewire's @entangle:

<div x-data="calendar({ mode: 'single', value: @entangle('date') })"></div>

Configuration

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

Option Type Default Description
mode 'single' | 'multiple' | 'range' 'single' Selection mode
display 'inline' | 'popup' 'inline' Inline calendar or popup with input
format string 'DD/MM/YYYY' Date format (tokens: DD, MM, YYYY, D, M, YY)
months number 1 Months to display (1=single, 2=dual side-by-side, 3+=scrollable)
mobileMonths number — Months to show on mobile (<640px). Only used when months is 2.
firstDay 0–6 1 First day of week (0=Sun, 1=Mon, ...)
mask boolean true Enable input masking
value string — Initial value (ISO or formatted string)
name string '' Input name attribute for form submission
locale string — BCP 47 locale for month/day names
timezone string — IANA timezone for resolving "today"
closeOnSelect boolean true Close popup after selection
wizard boolean | 'year-month' | 'month-day' false Birth date wizard mode
beforeSelect (date, ctx) => boolean — Custom validation before selection
showWeekNumbers boolean false Show ISO 8601 week numbers alongside the day grid
inputId string — ID for the popup input (allows external <label for="...">)
inputRef string 'rc-input' Alpine x-ref name for the input element
scrollHeight number 400 Max height (px) of scrollable container when months >= 3
presets RangePreset[] — Predefined date range shortcuts (see Range Presets)
constraintMessages ConstraintMessages — Custom tooltip strings for disabled dates
dateMetadata DateMetaProvider — Per-date metadata: labels, availability, colors (see Date Metadata)
template boolean true Auto-render template when no .rc-calendar exists

Date Constraints

Option Type Description
minDate string Earliest selectable date (ISO)
maxDate string Latest selectable date (ISO)
disabledDates string[] Specific dates to disable (ISO)
disabledDaysOfWeek number[] Days of week to disable (0=Sun, 6=Sat)
enabledDates string[] Force-enable specific dates (overrides day-of-week rules)
enabledDaysOfWeek number[] Only these days are selectable
disabledMonths number[] Months to disable (1=Jan, 12=Dec)
enabledMonths number[] Only these months are selectable
disabledYears number[] Specific years to disable
enabledYears number[] Only these years are selectable
minRange number Minimum range length in days (inclusive)
maxRange number Maximum range length in days (inclusive)
rules CalendarConfigRule[] Period-specific constraint overrides

Period-Specific Rules

Override constraints for specific date ranges. First matching rule wins; unmatched dates use global constraints.

<div x-data="calendar({
  mode: 'range',
  minRange: 3,
  rules: [
    {
      from: '2025-06-01',
      to: '2025-08-31',
      minRange: 7,
      disabledDaysOfWeek: [0, 6]
    }
  ]
})">

Reactive State

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

Property Type Description
mode string Current selection mode
display string 'inline' or 'popup'
month number Currently viewed month (1–12)
year number Currently viewed year
view string Current view: 'days', 'months', or 'years'
isOpen boolean Whether popup is open
grid MonthGrid[] Day grid data for rendering
monthGrid MonthCell[][] Month picker grid
yearGrid YearCell[][] Year picker grid
inputValue string Formatted selected value
focusedDate CalendarDate | null Keyboard-focused date
hoverDate CalendarDate | null Mouse-hovered date (for range preview)
wizardStep number Current wizard step (0=off, 1–3)
showWeekNumbers boolean Whether week numbers are displayed
presets RangePreset[] Configured range presets
isScrollable boolean Whether the calendar uses scrollable layout (months >= 3)

Computed Getters

Getter Type Description
selectedDates CalendarDate[] Array of selected dates
formattedValue string Formatted display string
hiddenInputValues string[] ISO strings for hidden form inputs
focusedDateISO string ISO string of focused date (for aria-activedescendant)
weekdayHeaders string[] Localized weekday abbreviations
yearLabel string Current year as string
decadeLabel string Decade range label (e.g., "2024 – 2035")
wizardStepLabel string Current wizard step name
canGoPrev boolean Whether backward navigation is possible
canGoNext boolean Whether forward navigation is possible

Methods

Navigation

Method Description
prev() Navigate to previous month/year/decade
next() Navigate to next month/year/decade
goToToday() Jump to current month
goTo(year, month?) Navigate to specific year/month
setView(view) Switch to 'days', 'months', or 'years'

Selection

Method Description
selectDate(date) Select or toggle a date
selectMonth(month) Select month in month picker
selectYear(year) Select year in year picker
clearSelection() Clear all selected dates
isSelected(date) Check if date is selected
isInRange(date, hover?) Check if date is within range
isRangeStart(date) Check if date is range start
isRangeEnd(date) Check if date is range end
applyPreset(index) Apply a range preset by index

Programmatic Control

Access these via $refs:

<div x-data="calendar({ ... })" x-ref="cal">
  <button @click="$refs.cal.setValue('2025-06-15')">Set Date</button>
  <button @click="$refs.cal.clear()">Clear</button>
</div>
Method Description
setValue(value) Set selection (ISO string, string[], or CalendarDate)
clear() Clear selection
goTo(year, month) Navigate without changing selection
open() / close() / toggle() Popup lifecycle
getSelection() Get current selection as CalendarDate[]
updateConstraints(options) Update constraints at runtime
updateDateMetadata(provider) Replace metadata at runtime (static map, callback, or null to clear)

Template Helpers

Method Description
dayClasses(cell) CSS class object for day cells
dayMeta(cell) Get DateMeta for a day cell (label, availability, color, cssClass)
dayStyle(cell) Inline style string for metadata color (--color-calendar-day-meta)
monthClasses(cell) CSS class object for month cells
yearClasses(cell) CSS class object for year cells
monthYearLabel(index) Formatted "Month Year" label for grid at index
handleKeydown(event) Keyboard navigation handler
handleFocus() Input focus handler (opens popup)
handleBlur() Input blur handler (parses typed value)

Input Binding

Method Description
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:

<div x-data="calendar({ ... })"
     @calendar:change="console.log($event.detail)"
     @calendar:navigate="console.log($event.detail)">
Event Detail Description
calendar:change { value, dates, formatted } Selection changed
calendar:navigate { year, month, view } Month/year navigation
calendar:open — Popup opened
calendar:close — Popup closed
calendar:view-change { view, year, month } View switched (days/months/years)

Keyboard Navigation

Key Action
Arrow keys Move focus between days
Enter / Space Select focused day
Page Down / Up Next / previous month
Shift + Page Down / Up Next / previous year
Home / End First / last day of month
Escape Close popup or return to day view

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;
  --shadow-calendar: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  --font-calendar: system-ui, -apple-system, sans-serif;
}

CSS Class Reference

All classes use the .rc- prefix:

Class Description
.rc-calendar Root container
.rc-header / .rc-header__nav / .rc-header__label Navigation header
.rc-weekdays / .rc-weekday Weekday header row
.rc-grid Day grid container
.rc-day Day cell
.rc-day--today Today's date
.rc-day--selected Selected date
.rc-day--range-start / .rc-day--range-end Range endpoints
.rc-day--in-range Dates within range
.rc-day--disabled Disabled date
.rc-day--other-month Leading/trailing days
.rc-day--focused Keyboard-focused date
.rc-day--available / .rc-day--unavailable Metadata availability states
.rc-day--has-label Day cell with a metadata label
.rc-day__number / .rc-day__label / .rc-day__dot Day cell inner elements (number, label text, availability dot)
.rc-month-grid / .rc-month Month picker
.rc-year-grid / .rc-year Year picker
.rc-months--dual Two-month side-by-side layout
.rc-nav--dual-hidden Hidden nav arrow in dual-month layout (prev on 2nd month)
.rc-nav--dual-next-first Next arrow on 1st month (hidden on desktop, visible on mobile)
.rc-nav--dual-next-last Next arrow on 2nd month (visible on desktop, hidden on mobile)
.rc-popup-overlay Popup backdrop
.rc-popup-header / .rc-popup-header__close Popup close header bar
.rc-calendar--wizard Wizard mode container
.rc-row--week-numbers / .rc-week-number Week number row and cell
.rc-grid--week-numbers Grid with week number column
.rc-presets / .rc-preset Range preset container and buttons
.rc-months--scroll Scrollable multi-month container
.rc-header--scroll-sticky Sticky header in scrollable layout
.rc-sr-only Screen reader only utility

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:

<div x-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:

<div x-data="calendar({
  mode: 'range',
  presets: [
    presetToday(),
    presetLastNDays(7),
    presetThisWeek(),
    presetThisMonth(),
    presetLastMonth()
  ]
})"></div>

Import the built-in factories:

import {
  presetToday,
  presetYesterday,
  presetLastNDays,
  presetThisWeek,
  presetLastWeek,
  presetThisMonth,
  presetLastMonth,
  presetThisYear,
  presetLastYear,
} from '@reachweb/alpine-calendar'

All factories accept an optional label and timezone parameter. presetThisWeek and presetLastWeek also accept a firstDay (default: 1 = Monday).

Custom presets:

const customPreset = {
  label: 'Next 30 Days',
  value: () => {
    const today = CalendarDate.today()
    return [today, today.addDays(29)]
  }
}

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:

<div x-data="calendar({
  mode: 'single',
  dateMetadata: {
    '2026-03-01': { label: '$120', availability: 'available' },
    '2026-03-05': { label: '$180', availability: 'available', color: '#ea580c' },
    '2026-03-06': { availability: 'unavailable' },
    '2026-03-07': { label: 'Sold', availability: 'unavailable' },
  }
})"></div>

Dynamic Callback

Use a function for computed metadata. Called for each visible date:

<div x-data="calendar({
  mode: 'range',
  dateMetadata: (date) => {
    const d = date.toNativeDate().getDay()
    if (d === 0 || d === 6) return { availability: 'unavailable' }
    return { label: '$' + (100 + date.day * 3), availability: 'available' }
  }
})"></div>

DateMeta Properties

Property Type Description
label string Text below the day number (e.g., price, event name)
availability 'available' | 'unavailable' 'available' shows a green dot, 'unavailable' disables selection with strikethrough
color string CSS color for the label and dot (e.g., '#16a34a')
cssClass string Custom CSS class(es) added to the day cell

All properties are optional and work independently. Dates with availability: 'unavailable' cannot be selected regardless of constraint settings.

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:

<div x-data="calendar({ mode: 'range', months: 6 })"></div>

<!-- Custom scroll height -->
<div x-data="calendar({ mode: 'range', months: 12, scrollHeight: 500 })"></div>

A sticky header tracks the currently visible month as you scroll. Default scroll height is 400px.

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 fewer months on mobile (e.g., mobileMonths: 1 with months: 2 displays a single month on narrow viewports).
  • Scrollable (3+ months): Smooth scroll with -webkit-overflow-scrolling: touch.
  • prefers-reduced-motion: All animations are disabled.

Mobile Months

When using months: 2, the calendar shows two months side-by-side on desktop and stacks them vertically on mobile. Set mobileMonths: 1 to show only a single month on mobile instead:

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

The calendar listens for viewport changes at the 640px breakpoint and switches between the desktop and mobile month counts automatically. Selection is preserved across viewport changes.

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

File Format Size (gzip) Use case
alpine-calendar.es.js ESM ~19KB Bundler (import)
alpine-calendar.umd.js UMD ~12KB Legacy (require())
alpine-calendar.cdn.js IIFE ~12KB CDN / <script> tag
alpine-calendar.css CSS ~4KB All environments

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'

import type {
  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:

<div wire:ignore>
  <div x-data="calendar({ mode: 'single', display: 'popup' })"
       @calendar:change="$wire.set('date', $event.detail.value)">
    <input x-ref="rc-input" type="text" class="rc-input">
  </div>
</div>

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

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors