View as Markdown

Calendar

A calendar for selecting a single date, several dates, or a range of dates.

100%
Loading...
<twig:Calendar
    mode="single"
    today="2026-03-15"
    selected="2026-03-15"
    captionLayout="dropdown"
    class="mx-auto rounded-lg border"
/>

Installation

Note

Available since UX Toolkit 3.5.

php bin/console ux:install calendar --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 twig/intl-extra symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Copy the following file(s) into your app:

assets/controllers/calendar_controller.js
import { Controller } from '@hotwired/stimulus';

const DAY = 86400000;

/**
 * Month navigation and date selection for the `Calendar` component.
 *
 * The grid is rendered server-side as a fixed six-by-seven table whose every day state is carried
 * by a `data-*` attribute, so navigating never rewrites markup nor class names: it walks the
 * existing cells and flips their attributes, leaving the Twig template the only source of classes.
 *
 * Dates are `Y-m-d` strings, converted to UTC timestamps only for arithmetic, so a daylight saving
 * transition can never shift a day by one.
 *
 * @value  mode            The selection mode, one of `single`, `multiple` or `range`.
 * @value  locale          The locale used to format the caption and day labels, falling back to the document language.
 * @value  name            The name of the hidden inputs mirroring the selection.
 * @value  month           The first displayed month, as a `Y-m-d` string.
 * @value  selected        The selected dates, as `Y-m-d` strings.
 * @value  disabled        The dates that cannot be selected, as `Y-m-d` strings.
 * @value  modifiers       Extra flags keyed by name, each holding a list of `Y-m-d` strings.
 * @value  today           The date considered as today, as a `Y-m-d` string.
 * @value  minDate         The earliest selectable date, as a `Y-m-d` string.
 * @value  maxDate         The latest selectable date, as a `Y-m-d` string.
 * @value  startMonth      The earliest month reachable through navigation, as a `Y-m-d` string.
 * @value  endMonth        The latest month reachable through navigation, as a `Y-m-d` string.
 * @value  weekStartsOn    The first day of the week, from `0` for Sunday to `6` for Saturday.
 * @value  numberOfMonths  The number of months displayed side by side.
 * @value  showOutsideDays Whether the days of the surrounding months are displayed.
 * @value  showWeekNumber  Whether the leading week number column is displayed.
 * @value  fixedWeeks      Whether every month always displays six weeks.
 * @target month           A displayed month, holding its caption and its grid.
 * @target previous        The button moving the calendar to the previous month.
 * @target next            The button moving the calendar to the next month.
 * @target input           A hidden input mirroring the selection.
 * @action previousMonth   Moves the calendar one month backwards.
 * @action nextMonth       Moves the calendar one month forwards.
 * @action goToMonth       Moves the calendar to the month and year picked in the dropdowns.
 * @action selectDate      Selects the date carried by the `date` param, or by the clicked day.
 * @action handleKeydown   Moves the focus between days with the arrow, home, end and page keys.
 * @action trackFocus      Reflects the focused day on its cell.
 * @action previewRange    Previews the range being drawn up to the hovered day.
 * @action clearPreview    Drops the range preview.
 */
export default class extends Controller {
    static targets = ['month', 'previous', 'next', 'input'];

    static values = {
        mode: { type: String, default: 'single' },
        locale: String,
        name: String,
        month: String,
        selected: Array,
        disabled: Array,
        modifiers: Object,
        today: String,
        minDate: String,
        maxDate: String,
        startMonth: String,
        endMonth: String,
        weekStartsOn: Number,
        numberOfMonths: { type: Number, default: 1 },
        showOutsideDays: { type: Boolean, default: true },
        showWeekNumber: Boolean,
        fixedWeeks: Boolean,
    };

    #connected = false;
    #focusDate = null;
    #preview = null;
    #rendered = null;
    #formatters = {};

    connect() {
        this.#focusDate =
            this.element.querySelector('[data-slot="calendar-day"] button[tabindex="0"]')?.dataset.day ??
            this.selectedValue[0] ??
            this.monthValue;
        this.#connected = true;
    }

    previousMonth() {
        if (!this.#canGoPrevious) {
            return;
        }

        this.monthValue = this.#addMonths(this.monthValue, -1);
    }

    nextMonth() {
        if (!this.#canGoNext) {
            return;
        }

        this.monthValue = this.#addMonths(this.monthValue, 1);
    }

    goToMonth(event) {
        const monthElement = event.target.closest('[data-slot="calendar-month"]');
        const selects = monthElement?.querySelectorAll('select');
        if (2 !== selects?.length) {
            return;
        }

        const month = String(selects[0].value).padStart(2, '0');
        this.monthValue = this.#addMonths(`${selects[1].value}-${month}-01`, -(event.params.index ?? 0));
    }

    selectDate(event) {
        const date = event.params?.date ?? event.currentTarget.dataset.day;
        if (!date || this.#isDisabled(date)) {
            return;
        }

        this.#preview = null;
        this.#focusDate = date;

        if (!this.#isDisplayed(date)) {
            this.monthValue = `${date.slice(0, 7)}-01`;
        }

        this.selectedValue = this.#nextSelection(date);
    }

    handleKeydown(event) {
        const button = event.target.closest('[data-slot="calendar-day"] button');
        if (!button) {
            return;
        }

        const current = button.dataset.day;
        const weekday = (new Date(this.#parse(current)).getUTCDay() - this.weekStartsOnValue + 7) % 7;
        const rtl = 'rtl' === getComputedStyle(this.element).direction;
        let target;

        switch (event.key) {
            case 'ArrowLeft':
                target = this.#shift(current, rtl ? 1 : -1);
                break;
            case 'ArrowRight':
                target = this.#shift(current, rtl ? -1 : 1);
                break;
            case 'ArrowUp':
                target = this.#shift(current, -7);
                break;
            case 'ArrowDown':
                target = this.#shift(current, 7);
                break;
            case 'Home':
                target = this.#shift(current, -weekday);
                break;
            case 'End':
                target = this.#shift(current, 6 - weekday);
                break;
            case 'PageUp':
                target = this.#clampToMonth(current, -1);
                break;
            case 'PageDown':
                target = this.#clampToMonth(current, 1);
                break;
            default:
                return;
        }

        event.preventDefault();
        this.#focusDate = target;

        // Stimulus reports a value change through a mutation observer, so it lands a microtask
        // too late for the focus below: render right away and let that later call deduplicate.
        if (!this.#isDisplayed(target)) {
            this.monthValue = `${target.slice(0, 7)}-01`;
        }

        this.#render();
        this.#dayButton(target)?.focus();
    }

    trackFocus(event) {
        const focused = 'focusin' === event.type ? event.target.closest('[data-slot="calendar-day"]') : null;

        for (const cell of this.#cells()) {
            cell.dataset.focused = String(cell === focused);
        }

        if (focused) {
            this.#focusDate = focused.dataset.day;
            this.#render();
        }
    }

    previewRange(event) {
        if ('range' !== this.modeValue || 1 !== this.selectedValue.length) {
            return;
        }

        const date = event.target.closest('[data-slot="calendar-day"]')?.dataset.day;
        if (!date || date === this.#preview || this.#isDisabled(date)) {
            return;
        }

        this.#preview = date;
        this.#render();
    }

    clearPreview() {
        if (null === this.#preview) {
            return;
        }

        this.#preview = null;
        this.#render();
    }

    monthValueChanged() {
        if (!this.#connected) {
            return;
        }

        this.#render();
        this.dispatch('month-change', { detail: { month: this.monthValue } });
    }

    selectedValueChanged() {
        if (!this.#connected) {
            return;
        }

        this.#render();
        this.#syncInputs();
        this.dispatch('select', { detail: { selected: this.selectedValue, mode: this.modeValue } });
    }

    get #canGoPrevious() {
        return '' === this.startMonthValue || this.monthValue > this.startMonthValue;
    }

    get #canGoNext() {
        const last = this.#addMonths(this.monthValue, this.numberOfMonthsValue - 1);

        return '' === this.endMonthValue || last < this.endMonthValue;
    }

    get #range() {
        if ('range' !== this.modeValue) {
            return [null, null];
        }

        const [from, to] = this.selectedValue;
        if (from && !to && this.#preview && this.#preview >= from) {
            return [from, this.#preview];
        }

        return [from ?? null, to ?? null];
    }

    #render() {
        const signature = [
            this.monthValue,
            this.selectedValue.join(','),
            this.#preview ?? '',
            this.#focusDate ?? '',
        ].join('|');

        if (signature === this.#rendered) {
            return;
        }

        this.#rendered = signature;
        this.monthTargets.forEach((monthElement, index) => this.#renderMonth(monthElement, index));

        for (const button of this.previousTargets) {
            button.setAttribute('aria-disabled', String(!this.#canGoPrevious));
        }
        for (const button of this.nextTargets) {
            button.setAttribute('aria-disabled', String(!this.#canGoNext));
        }
    }

    #renderMonth(monthElement, index) {
        const monthStart = this.#addMonths(this.monthValue, index);
        const prefix = monthStart.slice(0, 7);
        const caption = this.#format('caption', this.#parse(monthStart));

        monthElement.dataset.month = monthStart;
        monthElement.querySelector('[data-slot="calendar-grid"]')?.setAttribute('aria-label', caption);

        const selects = monthElement.querySelectorAll('select');
        const labels = monthElement.querySelectorAll('[data-slot="calendar-caption-label"]');
        if (2 === selects.length) {
            selects[0].value = String(Number(prefix.slice(5)));
            selects[1].value = prefix.slice(0, 4);
            labels[0].firstChild.textContent = this.#format('monthShort', this.#parse(monthStart));
            labels[1].firstChild.textContent = prefix.slice(0, 4);
        } else if (labels[0]) {
            labels[0].textContent = caption;
        }

        const lead = (new Date(this.#parse(monthStart)).getUTCDay() - this.weekStartsOnValue + 7) % 7;
        const gridStart = this.#parse(monthStart) - lead * DAY;
        const [from, to] = this.#range;
        const modifiers = Object.entries(this.modifiersValue);

        this.#cells(monthElement).forEach((cell, offset) => {
            const timestamp = gridStart + offset * DAY;
            const date = this.#toIso(timestamp);
            const outside = !date.startsWith(prefix);
            const rangeStart = null !== from && date === from;
            const rangeEnd = null !== to && date === to;
            const rangeMiddle = null !== from && null !== to && date > from && date < to;
            const selected =
                'range' === this.modeValue ? rangeStart || rangeEnd || rangeMiddle : this.selectedValue.includes(date);
            const disabled = this.#isDisabled(date);

            cell.dataset.day = date;
            cell.dataset.outside = String(outside);
            cell.dataset.today = String(date === this.todayValue);
            cell.dataset.selected = String(selected);
            cell.dataset.disabled = String(disabled);
            cell.dataset.hidden = String(outside && !this.showOutsideDaysValue);
            cell.dataset.rangeStart = String(rangeStart);
            cell.dataset.rangeMiddle = String(rangeMiddle);
            cell.dataset.rangeEnd = String(rangeEnd);
            cell.setAttribute('aria-selected', String(selected));

            for (const [name, dates] of modifiers) {
                cell.setAttribute(`data-${name}`, String(dates.includes(date)));
            }

            const button = cell.querySelector('button');
            button.textContent = this.#format('day', timestamp);
            button.dataset.day = date;
            button.dataset.calendarDateParam = date;
            button.dataset.selectedSingle = String('range' !== this.modeValue && selected);
            button.dataset.rangeStart = String(rangeStart);
            button.dataset.rangeMiddle = String(rangeMiddle);
            button.dataset.rangeEnd = String(rangeEnd);
            button.setAttribute('aria-label', this.#format('full', timestamp));
            button.disabled = disabled;
            button.tabIndex = date === this.#focusDate ? 0 : -1;
        });

        monthElement.querySelectorAll('[data-slot="calendar-week"]').forEach((row, week) => {
            const start = this.#toIso(gridStart + week * 7 * DAY);
            const end = this.#toIso(gridStart + (week * 7 + 6) * DAY);

            row.dataset.hidden = String(!this.fixedWeeksValue && !start.startsWith(prefix) && !end.startsWith(prefix));
        });

        monthElement.querySelectorAll('[data-slot="calendar-week-number"]').forEach((cell, week) => {
            const monday = gridStart + (week * 7 + ((1 - this.weekStartsOnValue + 7) % 7)) * DAY;

            cell.firstElementChild.textContent = String(this.#isoWeek(monday)).padStart(2, '0');
        });
    }

    #nextSelection(date) {
        if ('multiple' === this.modeValue) {
            return this.selectedValue.includes(date)
                ? this.selectedValue.filter((selected) => selected !== date)
                : [...this.selectedValue, date].sort();
        }

        if ('range' === this.modeValue) {
            const [from, to] = this.selectedValue;

            return !from || to || date < from ? [date] : [from, date];
        }

        return this.selectedValue.includes(date) ? [] : [date];
    }

    #syncInputs() {
        if ('' === this.nameValue) {
            return;
        }

        if ('range' === this.modeValue) {
            const [from, to] = this.selectedValue;
            for (const input of this.inputTargets) {
                input.value = ('from' === input.dataset.role ? from : to) ?? '';
            }

            return;
        }

        if ('multiple' !== this.modeValue) {
            if (this.hasInputTarget) {
                this.inputTarget.value = this.selectedValue[0] ?? '';
            }

            return;
        }

        for (const input of this.inputTargets) {
            input.remove();
        }
        for (const date of this.selectedValue) {
            const input = document.createElement('input');
            input.type = 'hidden';
            input.name = `${this.nameValue}[]`;
            input.value = date;
            input.dataset.calendarTarget = 'input';
            this.element.append(input);
        }
    }

    #isDisabled(date) {
        return (
            this.disabledValue.includes(date) ||
            ('' !== this.minDateValue && date < this.minDateValue) ||
            ('' !== this.maxDateValue && date > this.maxDateValue)
        );
    }

    #isDisplayed(date) {
        const month = `${date.slice(0, 7)}-01`;

        return month >= this.monthValue && month <= this.#addMonths(this.monthValue, this.numberOfMonthsValue - 1);
    }

    #cells(root = this.element) {
        return Array.from(root.querySelectorAll('[data-slot="calendar-day"]'));
    }

    #dayButton(date) {
        return this.element.querySelector(`[data-slot="calendar-day"][data-day="${date}"] button`);
    }

    #shift(date, days) {
        return this.#toIso(this.#parse(date) + days * DAY);
    }

    /** Moves by whole months, keeping the day of the month when the target month is shorter. */
    #clampToMonth(date, months) {
        const target = this.#addMonths(`${date.slice(0, 7)}-01`, months);
        const lastDay = new Date(this.#parse(this.#addMonths(target, 1)) - DAY).getUTCDate();

        return `${target.slice(0, 8)}${String(Math.min(Number(date.slice(8)), lastDay)).padStart(2, '0')}`;
    }

    #addMonths(month, count) {
        const total = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + count;

        return `${String(Math.floor(total / 12)).padStart(4, '0')}-${String((total % 12) + 1).padStart(2, '0')}-01`;
    }

    #parse(date) {
        return Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)));
    }

    #toIso(timestamp) {
        return new Date(timestamp).toISOString().slice(0, 10);
    }

    #isoWeek(timestamp) {
        const date = new Date(timestamp);
        date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7) + 3);

        const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
        firstThursday.setUTCDate(firstThursday.getUTCDate() - ((firstThursday.getUTCDay() + 6) % 7) + 3);

        return 1 + Math.round((date.getTime() - firstThursday.getTime()) / (7 * DAY));
    }

    #format(style, timestamp) {
        if (!this.#formatters[style]) {
            const options = {
                caption: { month: 'long', year: 'numeric' },
                monthShort: { month: 'short' },
                day: { day: 'numeric' },
                full: { dateStyle: 'full' },
            }[style];

            this.#formatters[style] = new Intl.DateTimeFormat(
                this.localeValue || document.documentElement.lang || undefined,
                { ...options, timeZone: 'UTC' }
            );
        }

        return this.#formatters[style].format(timestamp);
    }
}
assets/controllers/calendar_display_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * @target output  The form control reflecting the calendar selection.
 * @action update  Writes the calendar selection into the output target.
 */
export default class extends Controller {
    static targets = ['output'];

    update(event) {
        this.outputTarget.value = event.detail.selected.join(', ');
    }
}
templates/components/Calendar.html.twig
{%- props
    ## 'single'|'multiple'|'range' The selection mode.
    mode = 'single',
    ## string|null The name of the hidden inputs holding the selection, so the calendar can be submitted with a form.
    name = null,
    ## string|array<string>|null The initially selected dates, as `Y-m-d` strings. In `range` mode, the first entry is the start and the second one the end.
    selected = null,
    ## string|null The month to display, as a `Y-m-d` string.
    month = null,
    ## int The number of months displayed side by side.
    numberOfMonths = 1,
    ## 'label'|'dropdown' Whether the caption shows a plain label or month and year dropdowns.
    captionLayout = 'label',
    ## boolean Whether the days of the surrounding months are displayed.
    showOutsideDays = true,
    ## boolean Whether a leading column shows the ISO week number.
    showWeekNumber = false,
    ## boolean Whether every month always displays six weeks.
    fixedWeeks = false,
    ## int The first day of the week, from `0` for Sunday to `6` for Saturday.
    weekStartsOn = 0,
    ## string|null The locale used to format the month, weekday and day labels, falling back to the current request locale.
    locale = null,
    ## string|null The date considered as today, as a `Y-m-d` string.
    today = null,
    ## array<string> The dates that cannot be selected, as `Y-m-d` strings.
    disabled = [],
    ## string|null The earliest selectable date, as a `Y-m-d` string.
    minDate = null,
    ## string|null The latest selectable date, as a `Y-m-d` string.
    maxDate = null,
    ## string|null The earliest month reachable through navigation, as a `Y-m-d` string.
    startMonth = null,
    ## string|null The latest month reachable through navigation, as a `Y-m-d` string.
    endMonth = null,
    ## array<string,array<string>> Extra flags keyed by name, each holding a list of `Y-m-d` strings, rendered as `data-<name>` on the matching days.
    modifiers = {},
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The style variant of the navigation buttons.
    buttonVariant = 'ghost',
    ## string The accessible name of the calendar.
    label = 'Calendar'
-%}
{%- set _locale = locale ?? app.request.locale|default(null) -%}
{%- set _today = (today ?? 'today')|date('Y-m-d') -%}
{%- set _selected = null == selected ? [] : (selected is iterable ? selected|map(date => date|date('Y-m-d')) : [selected|date('Y-m-d')]) -%}
{%- set _rangeStart = 'range' == mode and _selected|length > 0 ? _selected[0] : null -%}
{%- set _rangeEnd = 'range' == mode and _selected|length > 1 ? _selected[1] : null -%}
{%- set _month = (month ?? (_selected is empty ? _today : _selected|first))|date('Y-m-01') -%}
{%- set _lastMonth = _month|date_modify('+' ~ (numberOfMonths - 1) ~ ' months')|date('Y-m-01') -%}
{%- set _startMonth = (startMonth ?? _today|date_modify('-100 years'))|date('Y-m-01') -%}
{%- set _endMonth = (endMonth ?? _today|date_modify('+10 years'))|date('Y-m-01') -%}
{%- set _disabled = disabled|map(date => date|date('Y-m-d')) -%}
{%- set _minDate = minDate ? minDate|date('Y-m-d') : null -%}
{%- set _maxDate = maxDate ? maxDate|date('Y-m-d') : null -%}
{%- set _modifiers = {} -%}
{%- for _name, _dates in modifiers -%}
    {%- set _modifiers = _modifiers|merge({(_name): _dates|map(date => date|date('Y-m-d'))}) -%}
{%- endfor -%}
{%- set _focusDate = _selected is not empty ? _selected|first : (_today >= _month and _today <= _lastMonth|date_modify('last day of this month')|date('Y-m-d') ? _today : _month) -%}
{%- set _dayClass = html_classes(
    'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)',
    {
        '[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)': showWeekNumber,
        '[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)': not showWeekNumber,
    },
    'data-[today=true]:rounded-(--cell-radius) data-[today=true]:bg-muted data-[today=true]:text-foreground data-[today=true]:data-[selected=true]:rounded-none',
    'data-[outside=true]:text-muted-foreground data-[outside=true]:data-[selected=true]:text-muted-foreground',
    'data-[disabled=true]:text-muted-foreground data-[disabled=true]:opacity-50 data-[hidden=true]:invisible',
    'data-[range-start=true]:isolate data-[range-start=true]:z-0 data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-muted data-[range-start=true]:after:absolute data-[range-start=true]:after:inset-y-0 data-[range-start=true]:after:end-0 data-[range-start=true]:after:w-4 data-[range-start=true]:after:bg-muted',
    'data-[range-middle=true]:rounded-none',
    'data-[range-end=true]:isolate data-[range-end=true]:z-0 data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-muted data-[range-end=true]:after:absolute data-[range-end=true]:after:inset-y-0 data-[range-end=true]:after:start-0 data-[range-end=true]:after:w-4 data-[range-end=true]:after:bg-muted',
) -%}
{%- set _dayButtonClass = html_classes(
    'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal',
    'group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50',
    'data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground',
    'data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground',
    'data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground',
    'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground',
    'dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
) -%}
{# A known Sunday, so the weekday headers can be derived from `weekStartsOn` alone. #}
{%- set _weekdayReference = '2024-01-07' -%}
<div
    role="group"
    aria-label="{{ label }}"
    data-slot="calendar"
    data-mode="{{ mode }}"
    data-calendar-mode-value="{{ mode }}"
    data-calendar-locale-value="{{ _locale }}"
    data-calendar-name-value="{{ name }}"
    data-calendar-month-value="{{ _month }}"
    data-calendar-selected-value="{{ _selected|json_encode }}"
    data-calendar-disabled-value="{{ _disabled|json_encode }}"
    data-calendar-modifiers-value="{{ _modifiers is empty ? '{}' : _modifiers|json_encode }}"
    data-calendar-today-value="{{ _today }}"
    data-calendar-min-date-value="{{ _minDate }}"
    data-calendar-max-date-value="{{ _maxDate }}"
    data-calendar-start-month-value="{{ _startMonth }}"
    data-calendar-end-month-value="{{ _endMonth }}"
    data-calendar-week-starts-on-value="{{ weekStartsOn }}"
    data-calendar-number-of-months-value="{{ numberOfMonths }}"
    data-calendar-show-outside-days-value="{{ showOutsideDays ? 'true' : 'false' }}"
    data-calendar-show-week-number-value="{{ showWeekNumber ? 'true' : 'false' }}"
    data-calendar-fixed-weeks-value="{{ fixedWeeks ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'group/calendar w-fit bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent'|tailwind_classes,
        'data-controller': 'calendar',
        'data-action': [
            'keydown->calendar#handleKeydown',
            'focusin->calendar#trackFocus',
            'focusout->calendar#trackFocus',
            'pointerover->calendar#previewRange',
            'pointerleave->calendar#clearPreview',
        ]|join(' ')|html_attr_type('sst'),
    }) }}
>
    <div data-slot="calendar-months" class="relative flex flex-col gap-4 md:flex-row">
        <nav data-slot="calendar-nav" class="absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1">
            <twig:Button
                variant="{{ buttonVariant }}"
                data-calendar-target="previous"
                data-action="click->calendar#previousMonth"
                aria-disabled="{{ _month > _startMonth ? 'false' : 'true' }}"
                class="size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
            >
                <twig:ux:icon name="lucide:chevron-left" class="size-4 rtl:rotate-180" />
                <span class="sr-only">Previous month</span>
            </twig:Button>
            <twig:Button
                variant="{{ buttonVariant }}"
                data-calendar-target="next"
                data-action="click->calendar#nextMonth"
                aria-disabled="{{ _lastMonth < _endMonth ? 'false' : 'true' }}"
                class="size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
            >
                <twig:ux:icon name="lucide:chevron-right" class="size-4 rtl:rotate-180" />
                <span class="sr-only">Next month</span>
            </twig:Button>
        </nav>

        {%- for monthOffset in 0..(numberOfMonths - 1) -%}
            {%- set _current = _month|date_modify('+' ~ monthOffset ~ ' months')|date('Y-m-01') -%}
            {%- set _lead = (_current|date('w') - weekStartsOn + 7) % 7 -%}
            {%- set _gridStart = _current|date_modify('-' ~ _lead ~ ' days')|date('Y-m-d') -%}
            <div data-slot="calendar-month" data-calendar-target="month" data-month="{{ _current }}" class="flex w-full flex-col gap-4">
                <div data-slot="calendar-month-caption" class="flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)">
                    {%- if 'dropdown' == captionLayout -%}
                        <div data-slot="calendar-dropdowns" class="flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium">
                            <span data-slot="calendar-dropdown-root" class="relative rounded-(--cell-radius) border border-input has-focus:border-ring has-focus:ring-3 has-focus:ring-ring/50">
                                <select
                                    aria-label="Month"
                                    data-action="change->calendar#goToMonth"
                                    data-calendar-index-param="{{ monthOffset }}"
                                    class="absolute inset-0 bg-popover opacity-0"
                                >
                                    {%- for monthNumber in 1..12 -%}
                                        <option value="{{ monthNumber }}"{% if monthNumber == _current|date('n') %} selected{% endif %}>{{ ('2024-' ~ '%02d'|format(monthNumber) ~ '-01')|format_date(pattern: 'LLL', locale: _locale) }}</option>
                                    {%- endfor -%}
                                </select>
                                <span data-slot="calendar-caption-label" class="flex h-6 items-center gap-1 rounded-(--cell-radius) ps-1.5 pe-1 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground">
                                    {{- _current|format_date(pattern: 'LLL', locale: _locale) -}}
                                    <twig:ux:icon name="lucide:chevron-down" />
                                </span>
                            </span>
                            <span data-slot="calendar-dropdown-root" class="relative rounded-(--cell-radius) border border-input has-focus:border-ring has-focus:ring-3 has-focus:ring-ring/50">
                                <select
                                    aria-label="Year"
                                    data-action="change->calendar#goToMonth"
                                    data-calendar-index-param="{{ monthOffset }}"
                                    class="absolute inset-0 bg-popover opacity-0"
                                >
                                    {%- for year in _startMonth|date('Y')..(_endMonth|date('Y')) -%}
                                        <option value="{{ year }}"{% if year == _current|date('Y') %} selected{% endif %}>{{ year }}</option>
                                    {%- endfor -%}
                                </select>
                                <span data-slot="calendar-caption-label" class="flex h-6 items-center gap-1 rounded-(--cell-radius) ps-1.5 pe-1 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground">
                                    {{- _current|format_date(pattern: 'y', locale: _locale) -}}
                                    <twig:ux:icon name="lucide:chevron-down" />
                                </span>
                            </span>
                        </div>
                    {%- else -%}
                        <span data-slot="calendar-caption-label" class="text-sm font-medium select-none">{{ _current|format_date(pattern: 'LLLL y', locale: _locale) }}</span>
                    {%- endif -%}
                </div>

                <table role="grid" aria-label="{{ _current|format_date(pattern: 'LLLL y', locale: _locale) }}" data-slot="calendar-grid" class="w-full border-collapse">
                    <thead>
                        <tr class="flex">
                            {%- if showWeekNumber -%}
                                <th scope="col" data-slot="calendar-week-number-header" class="w-(--cell-size) select-none"><span class="sr-only">Week</span></th>
                            {%- endif -%}
                            {%- for weekdayOffset in 0..6 -%}
                                {%- set _weekday = _weekdayReference|date_modify('+' ~ ((weekStartsOn + weekdayOffset) % 7) ~ ' days')|date('Y-m-d') -%}
                                <th scope="col" aria-label="{{ _weekday|format_date(pattern: 'EEEE', locale: _locale) }}" data-slot="calendar-weekday" class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none">
                                    {{- _weekday|format_date(pattern: 'EEEEEE', locale: _locale) -}}
                                </th>
                            {%- endfor -%}
                        </tr>
                    </thead>
                    <tbody>
                        {%- for week in 0..5 -%}
                            {%- set _weekStart = _gridStart|date_modify('+' ~ (week * 7) ~ ' days')|date('Y-m-d') -%}
                            {%- set _weekEnd = _weekStart|date_modify('+6 days')|date('Y-m-d') -%}
                            {%- set _weekOutside = _weekStart|date('Y-m') != _current|date('Y-m') and _weekEnd|date('Y-m') != _current|date('Y-m') -%}
                            <tr data-slot="calendar-week" data-hidden="{{ not fixedWeeks and _weekOutside ? 'true' : 'false' }}" class="mt-2 flex w-full data-[hidden=true]:hidden">
                                {%- if showWeekNumber -%}
                                    <td data-slot="calendar-week-number" class="text-[0.8rem] text-muted-foreground select-none">
                                        <div class="flex size-(--cell-size) items-center justify-center text-center">{{ _weekStart|date_modify('+' ~ ((1 - weekStartsOn + 7) % 7) ~ ' days')|date('W') }}</div>
                                    </td>
                                {%- endif -%}
                                {%- for dayOffset in 0..6 -%}
                                    {%- set _date = _weekStart|date_modify('+' ~ dayOffset ~ ' days')|date('Y-m-d') -%}
                                    {%- set _outside = _date|date('Y-m') != _current|date('Y-m') -%}
                                    {%- set _isDisabled = _date in _disabled or (_minDate and _date < _minDate) or (_maxDate and _date > _maxDate) -%}
                                    {%- set _isRangeStart = _rangeStart and _date == _rangeStart -%}
                                    {%- set _isRangeEnd = _rangeEnd and _date == _rangeEnd -%}
                                    {%- set _isRangeMiddle = _rangeStart and _rangeEnd and _date > _rangeStart and _date < _rangeEnd -%}
                                    {%- set _isSelected = 'range' == mode ? (_isRangeStart or _isRangeEnd or _isRangeMiddle) : _date in _selected -%}
                                    <td
                                        data-slot="calendar-day"
                                        data-day="{{ _date }}"
                                        data-outside="{{ _outside ? 'true' : 'false' }}"
                                        data-today="{{ _date == _today ? 'true' : 'false' }}"
                                        data-selected="{{ _isSelected ? 'true' : 'false' }}"
                                        data-disabled="{{ _isDisabled ? 'true' : 'false' }}"
                                        data-focused="false"
                                        data-hidden="{{ _outside and not showOutsideDays ? 'true' : 'false' }}"
                                        data-range-start="{{ _isRangeStart ? 'true' : 'false' }}"
                                        data-range-middle="{{ _isRangeMiddle ? 'true' : 'false' }}"
                                        data-range-end="{{ _isRangeEnd ? 'true' : 'false' }}"
                                        aria-selected="{{ _isSelected ? 'true' : 'false' }}"
                                        {%- for _name, _dates in _modifiers %} data-{{ _name }}="{{ _date in _dates ? 'true' : 'false' }}"{% endfor %}
                                        class="{{ _dayClass }}"
                                    >
                                        <twig:Button
                                            variant="ghost"
                                            size="icon"
                                            data-action="click->calendar#selectDate"
                                            data-calendar-date-param="{{ _date }}"
                                            data-day="{{ _date }}"
                                            data-selected-single="{{ 'range' != mode and _isSelected ? 'true' : 'false' }}"
                                            data-range-start="{{ _isRangeStart ? 'true' : 'false' }}"
                                            data-range-middle="{{ _isRangeMiddle ? 'true' : 'false' }}"
                                            data-range-end="{{ _isRangeEnd ? 'true' : 'false' }}"
                                            aria-label="{{ _date|format_date(dateFormat: 'full', locale: _locale) }}"
                                            tabindex="{{ _date == _focusDate ? '0' : '-1' }}"
                                            class="{{ _dayButtonClass }}"
                                            :disabled="_isDisabled"
                                        >
                                            {{- _date|format_date(pattern: 'd', locale: _locale) -}}
                                        </twig:Button>
                                    </td>
                                {%- endfor -%}
                            </tr>
                        {%- endfor -%}
                    </tbody>
                </table>
            </div>
        {%- endfor -%}
    </div>

    {%- if name -%}
        {%- if 'range' == mode -%}
            <input type="hidden" name="{{ name }}[from]" value="{{ _rangeStart }}" data-calendar-target="input" data-role="from" />
            <input type="hidden" name="{{ name }}[to]" value="{{ _rangeEnd }}" data-calendar-target="input" data-role="to" />
        {%- elseif 'multiple' == mode -%}
            {%- for _date in _selected -%}
                <input type="hidden" name="{{ name }}[]" value="{{ _date }}" data-calendar-target="input" />
            {%- endfor -%}
        {%- else -%}
            <input type="hidden" name="{{ name }}" value="{{ _selected|first }}" data-calendar-target="input" />
        {%- endif -%}
    {%- endif -%}
    {##- Optional content rendered below the month grids, such as a footer. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="button"
    data-size="{{ size }}"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>

Usage

<twig:Calendar
    mode="single | multiple | range"
    name="date"
    selected="2026-03-15"
    month="2026-03-01"
    captionLayout="label | dropdown"
/>

Dates are exchanged as Y-m-d strings. When name is set, the selection is mirrored into hidden inputs (name, name[] in multiple mode, name[from] and name[to] in range mode) so the calendar can be submitted with a form. Selecting a date also dispatches a calendar:select event, and navigating to another month dispatches a calendar:month-change event; both bubble, so an ancestor element can listen for them. This is the way to react to the selection without reading the hidden inputs.

Examples

Basic

A calendar with no initial selection. Use class="rounded-lg border" to frame it.

100%
Loading...
<twig:Calendar
    mode="single"
    today="2026-03-15"
    month="2026-03-01"
    class="mx-auto rounded-lg border"
/>

Range Calendar

Use mode="range" to select a period. The first entry of selected is the start of the range, the second one its end.

100%
Loading...
<twig:Card class="mx-auto w-fit p-0">
    <twig:Card:Content class="p-0">
        <twig:Calendar
            mode="range"
            today="2026-03-15"
            month="2026-01-01"
            :selected="['2026-01-12', '2026-02-11']"
            numberOfMonths="2"
            minDate="1900-01-01"
            maxDate="2026-03-15"
        />
    </twig:Card:Content>
</twig:Card>

Multiple

Use mode="multiple" to select any number of individual dates.

100%
Loading...
<twig:Card class="mx-auto w-fit p-0">
    <twig:Card:Content class="p-0">
        <twig:Calendar
            mode="multiple"
            today="2026-03-15"
            month="2026-03-01"
            :selected="['2026-03-04', '2026-03-11', '2026-03-18']"
        />
    </twig:Card:Content>
</twig:Card>

Month and Year Selector

Use captionLayout="dropdown" to replace the caption with month and year dropdowns. Their range is bounded by startMonth and endMonth.

100%
Loading...
<twig:Calendar
    mode="single"
    today="2026-03-15"
    month="2026-03-01"
    captionLayout="dropdown"
    startMonth="2024-01-01"
    endMonth="2028-12-01"
    class="mx-auto rounded-lg border"
/>

Presets

Anything placed inside the calendar is rendered below the grid, and is within reach of the calendar controller: a button carrying data-action="click->calendar#selectDate" and a data-calendar-date-param selects that date and jumps to its month.

100%
Loading...
<twig:Card size="sm" class="mx-auto w-fit max-w-[300px]">
    <twig:Card:Content>
        <twig:Calendar
            mode="single"
            today="2026-03-15"
            selected="2026-02-12"
            fixedWeeks
            class="p-0 [--cell-size:--spacing(9.5)]"
        >
            <div class="mt-3 flex flex-wrap gap-2 border-t pt-3">
                {% for preset in [{label: 'Today', days: 0}, {label: 'Tomorrow', days: 1}, {label: 'In 3 days', days: 3}, {label: 'In a week', days: 7}, {label: 'In 2 weeks', days: 14}] %}
                    <twig:Button
                        variant="outline"
                        size="sm"
                        class="flex-1"
                        data-action="click->calendar#selectDate"
                        data-calendar-date-param="{{ '2026-03-15'|date_modify('+' ~ preset.days ~ ' days')|date('Y-m-d') }}"
                    >{{ preset.label }}</twig:Button>
                {% endfor %}
            </div>
        </twig:Calendar>
    </twig:Card:Content>
</twig:Card>

Date and Time Picker

100%
Loading...
<twig:Card size="sm" class="mx-auto w-fit">
    <twig:Card:Content>
        <twig:Calendar
            mode="single"
            today="2026-03-15"
            selected="2026-03-12"
            class="p-0"
        />
    </twig:Card:Content>
    <twig:Card:Footer class="border-t bg-card">
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="time-from">Start Time</twig:Field:Label>
                <twig:InputGroup>
                    <twig:InputGroup:Input id="time-from" type="time" step="1" value="10:30:00" class="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
                    <twig:InputGroup:Addon>
                        <twig:ux:icon name="lucide:clock" class="text-muted-foreground" />
                    </twig:InputGroup:Addon>
                </twig:InputGroup>
            </twig:Field>
            <twig:Field>
                <twig:Field:Label for="time-to">End Time</twig:Field:Label>
                <twig:InputGroup>
                    <twig:InputGroup:Input id="time-to" type="time" step="1" value="12:30:00" class="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
                    <twig:InputGroup:Addon>
                        <twig:ux:icon name="lucide:clock" class="text-muted-foreground" />
                    </twig:InputGroup:Addon>
                </twig:InputGroup>
            </twig:Field>
        </twig:Field:Group>
    </twig:Card:Footer>
</twig:Card>

Booked Dates

Every entry of modifiers is rendered as a data-<name> attribute on the matching days, which is enough to style them with an arbitrary utility.

100%
Loading...
{% set bookedDates = (0..14)|map(offset => '2026-02-12'|date_modify('+' ~ offset ~ ' days')|date('Y-m-d')) %}
<twig:Card class="mx-auto w-fit p-0">
    <twig:Card:Content class="p-0">
        <twig:Calendar
            mode="single"
            today="2026-03-15"
            month="2026-02-01"
            selected="2026-02-03"
            :disabled="bookedDates"
            :modifiers="{booked: bookedDates}"
            class="[&_[data-booked=true][data-disabled=true]]:opacity-100 [&_[data-booked=true]>button]:line-through"
        />
    </twig:Card:Content>
</twig:Card>

Custom Cell Size

Cells are sized with the --cell-size CSS variable, which can be overridden per breakpoint.

100%
Loading...
<twig:Card class="mx-auto w-fit p-0">
    <twig:Card:Content class="p-0">
        <twig:Calendar
            mode="range"
            today="2026-03-15"
            month="2026-12-01"
            :selected="['2026-12-08', '2026-12-18']"
            captionLayout="dropdown"
            class="[--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)]"
        />
    </twig:Card:Content>
</twig:Card>

Week Numbers

Use showWeekNumber to prepend a column with the ISO week number.

100%
Loading...
<twig:Card class="mx-auto w-fit p-0">
    <twig:Card:Content class="p-0">
        <twig:Calendar
            mode="single"
            today="2026-03-15"
            month="2026-02-01"
            selected="2026-02-03"
            showWeekNumber
        />
    </twig:Card:Content>
</twig:Card>

API

The calendar dispatches a calendar:select event on every selection change, with selected (the dates, as Y-m-d strings) and mode in its detail, and a calendar:month-change event on every navigation, with month in its detail. Both events bubble. The calendar-display controller shipped with this recipe listens to calendar:select and mirrors the selection into a visible read-only field.

100%
Loading...
<div class="mx-auto flex w-fit flex-col gap-4" data-controller="calendar-display" data-action="calendar:select->calendar-display#update">
    <twig:Calendar
        mode="range"
        name="stay"
        today="2026-03-15"
        :selected="['2026-03-12', '2026-03-18']"
        class="rounded-lg border"
    />
    <twig:Field>
        <twig:Field:Label for="calendar-api-summary">Selected dates</twig:Field:Label>
        <twig:Input id="calendar-api-summary" data-calendar-display-target="output" value="2026-03-12, 2026-03-18" readonly />
    </twig:Field>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element. Pair it with locale so the month, weekday and day labels are formatted for that language, and with weekStartsOn so the week starts on the expected day.

The -u-nu- Unicode extension pins the numbering system the digits are drawn from. Worth setting explicitly for languages that have more than one in use: a bare ar resolves to Arabic-Indic digits or Latin ones depending on the ICU version, and the server and the browser do not necessarily ship the same one.

100%
Loading...
<div class="flex flex-col items-center gap-12">
    {# Arabic #}
    <twig:Calendar
        dir="rtl"
        locale="ar-u-nu-latn"
        weekStartsOn="6"
        mode="single"
        today="2026-03-15"
        selected="2026-03-15"
        captionLayout="dropdown"
        class="rounded-lg border [--cell-size:--spacing(9)]"
    />

    {# Hebrew #}
    <twig:Calendar
        dir="rtl"
        locale="he-u-nu-latn"
        weekStartsOn="0"
        mode="single"
        today="2026-03-15"
        selected="2026-03-15"
        captionLayout="dropdown"
        class="rounded-lg border [--cell-size:--spacing(9)]"
    />
</div>

Accessibility

  • The root element renders role="group" with an aria-label taken from the label prop (Calendar by default), and each displayed month is a <table role="grid"> whose aria-label is the localized month and year.
  • Weekday headers are <th scope="col"> showing a narrow abbreviation, with the full weekday name exposed through aria-label. Every day cell carries aria-selected, and its button has an aria-label holding the full localized date, so a screen reader announces the whole date rather than a bare number.
  • Only one day button per calendar is in the tab order (roving tabindex): the selected day, today, or the first of the displayed month. From there, arrow keys move by one day and one week, Home and End jump to the edges of the week, PageUp and PageDown move by one month, and left and right are swapped in RTL. Moving outside the displayed month navigates to it.
  • The previous and next month buttons are icon-only with a visually hidden label, hardcoded in the template, so translate them there. They use aria-disabled rather than disabled at the navigation bounds, so they stay reachable by keyboard and simply do nothing.
  • With captionLayout="dropdown", the month and year <select> elements are transparent overlays over the visible caption, each named by an aria-label (Month and Year).
  • With showWeekNumber, the week-number column header is a visually hidden Week.
  • Month, weekday and day labels are localized through the locale prop, falling back to the request locale, so the announced dates follow the user's language.

API Reference

<twig:Calendar>

Prop Type Default
mode 
'single'|'multiple'|'range' 'single'
name 
string|null null
selected 
string|array<string>|null null
month 
string|null null
numberOfMonths 
int 1
captionLayout 
'label'|'dropdown' 'label'
showOutsideDays 
boolean true
showWeekNumber 
boolean false
fixedWeeks 
boolean false
weekStartsOn 
int 0
locale 
string|null null
today 
string|null null
disabled 
array<string> []
minDate 
string|null null
maxDate 
string|null null
startMonth 
string|null null
endMonth 
string|null null
modifiers 
array<string,array<string>> []
buttonVariant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'ghost'
label 
string 'Calendar'
Block Description
content Optional content rendered below the month grids, such as a footer.

data-controller="calendar"

Value Type Default
data-calendar-mode-value 
String 'single'
data-calendar-locale-value 
String -
data-calendar-name-value 
String -
data-calendar-month-value 
String -
data-calendar-selected-value 
Array -
data-calendar-disabled-value 
Array -
data-calendar-modifiers-value 
Object -
data-calendar-today-value 
String -
data-calendar-min-date-value 
String -
data-calendar-max-date-value 
String -
data-calendar-start-month-value 
String -
data-calendar-end-month-value 
String -
data-calendar-week-starts-on-value 
Number -
data-calendar-number-of-months-value 
Number 1
data-calendar-show-outside-days-value 
Boolean true
data-calendar-show-week-number-value 
Boolean -
data-calendar-fixed-weeks-value 
Boolean -
Target Description
month A displayed month, holding its caption and its grid.
previous The button moving the calendar to the previous month.
next The button moving the calendar to the next month.
input A hidden input mirroring the selection.
Action Description
previousMonth Moves the calendar one month backwards.
nextMonth Moves the calendar one month forwards.
goToMonth Moves the calendar to the month and year picked in the dropdowns.
selectDate Selects the date carried by the date param, or by the clicked day.
handleKeydown Moves the focus between days with the arrow, home, end and page keys.
trackFocus Reflects the focused day on its cell.
previewRange Previews the range being drawn up to the hovered day.
clearPreview Drops the range preview.

data-controller="calendar-display"

Target Description
output The form control reflecting the calendar selection.
Action Description
update Writes the calendar selection into the output target.