View as Markdown

Date Picker

A date picker built by composing a Popover and a Calendar.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
    <twig:DatePicker>
        <twig:DatePicker:Trigger>
            <twig:Button
                variant="outline"
                class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
                {{ ...date_picker_trigger_attrs }}
            >
                <twig:DatePicker:Value placeholder="Pick a date" />
                <twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
            </twig:Button>
        </twig:DatePicker:Trigger>
        <twig:DatePicker:Content>
            <twig:Calendar mode="single" today="2026-03-15" />
        </twig:DatePicker:Content>
    </twig:DatePicker>
</div>

Installation

Note

Available since UX Toolkit 3.5.

php bin/console ux:install date-picker --kit shadcn

Install the following Composer dependencies:

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

Copy the following file(s) into your app:

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

/**
 * Glue between the `Popover` and the `Calendar` composing the `DatePicker`.
 *
 * Both controllers live on the same element, so the popover is driven by writing its own value
 * attribute rather than by holding a reference to it, and the calendar is reached through the
 * Stimulus application registry.
 *
 * Dates are `Y-m-d` strings, the exchange format of the `Calendar`, and are only turned into a
 * `Date` to be formatted or parsed, at UTC midnight so a time zone west of Greenwich can never
 * move the day.
 *
 * @value  locale        The locale used to format the selection.
 * @value  dateStyle     The length of the formatted selection, one of `full`, `long`, `medium` or `short`.
 * @value  separator     The text placed between two formatted dates.
 * @value  closeOnSelect Whether completing the selection closes the popover.
 * @target trigger       The element opening the popover, flagged while nothing is selected.
 * @target value         The element displaying the formatted selection.
 * @target input         The text input mirroring the selection.
 * @action select        Reflects a calendar selection on the trigger, the value and the input.
 * @action parseInput    Moves the calendar to the date typed in the input.
 * @action open          Opens the popover.
 */
export default class extends Controller {
    static targets = ['trigger', 'value', 'input'];

    static values = {
        locale: String,
        dateStyle: { type: String, default: 'long' },
        separator: { type: String, default: ' - ' },
        closeOnSelect: { type: Boolean, default: true },
    };

    // Signature of the selection this controller just pushed onto the calendar, so the
    // `calendar:select` it echoes back is not mistaken for a click on a day.
    #pushed = null;
    #formatter = null;

    select(event) {
        const { selected = [], mode = 'single' } = event.detail ?? {};
        const echo = selected.join(',') === this.#pushed;
        this.#pushed = null;

        this.#reflect(selected, echo);

        if (!echo && this.closeOnSelectValue && this.#isComplete(selected, mode)) {
            this.#setOpen(false);
        }
    }

    parseInput(event) {
        const date = this.#parse(event.currentTarget.value);
        if (null === date) {
            return;
        }

        const calendar = this.#calendar;
        if (null === calendar) {
            return;
        }

        const month = `${date.slice(0, 7)}-01`;
        if (calendar.monthValue !== month) {
            calendar.monthValue = month;
        }

        if (calendar.selectedValue.join(',') !== date) {
            this.#pushed = date;
            calendar.selectedValue = [date];
        }
    }

    open(event) {
        event?.preventDefault();
        this.#setOpen(true);
    }

    get #calendar() {
        const element = this.element.querySelector('[data-slot="calendar"]');
        if (null === element) {
            return null;
        }

        return this.application.getControllerForElementAndIdentifier(element, 'calendar');
    }

    #setOpen(open) {
        this.element.setAttribute('data-popover-open-value', String(open));
    }

    #reflect(selected, skipInput = false) {
        const empty = 0 === selected.length;
        const formatted = selected.map((date) => this.#format(date)).join(this.separatorValue);

        this.element.dataset.empty = String(empty);

        for (const trigger of this.triggerTargets) {
            trigger.dataset.empty = String(empty);
        }

        for (const value of this.valueTargets) {
            value.dataset.empty = String(empty);
            value.textContent = empty ? (value.dataset.placeholder ?? '') : formatted;
        }

        if (skipInput) {
            return;
        }

        for (const input of this.inputTargets) {
            input.value = formatted;
        }
    }

    #isComplete(selected, mode) {
        if (0 === selected.length || 'multiple' === mode) {
            return false;
        }

        return 'range' !== mode || selected.length > 1;
    }

    #format(date) {
        this.#formatter ??= new Intl.DateTimeFormat(this.localeValue || document.documentElement.lang || undefined, {
            dateStyle: this.dateStyleValue,
            timeZone: 'UTC',
        });

        return this.#formatter.format(new Date(`${date}T00:00:00Z`));
    }

    #parse(text) {
        const value = text.trim();
        if ('' === value) {
            return null;
        }

        // `Y-m-d` is parsed as UTC by `Date`, so it is validated as-is rather than read back
        // through the local getters, which would shift the day west of Greenwich.
        if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
            const parsed = new Date(`${value}T00:00:00Z`);

            return !Number.isNaN(parsed.getTime()) && value === parsed.toISOString().slice(0, 10) ? value : null;
        }

        const timestamp = Date.parse(value);

        return Number.isNaN(timestamp) ? null : this.#toDateString(new Date(timestamp));
    }

    #toDateString(date) {
        return [
            date.getFullYear(),
            String(date.getMonth() + 1).padStart(2, '0'),
            String(date.getDate()).padStart(2, '0'),
        ].join('-');
    }
}
templates/components/DatePicker.html.twig
{%- props
    ## 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 locale used to format the selection.
    locale = null,
    ## 'full'|'long'|'medium'|'short' The length of the formatted selection.
    dateStyle = 'long',
    ## string The text placed between two formatted dates.
    separator = ' - ',
    ## boolean Whether completing the selection closes the popover.
    closeOnSelect = true,
    ## boolean Whether the popover is open on initial render.
    open = false
-%}
{%- set _selected = null == selected ? [] : (selected is iterable ? selected|map(date => date|date('Y-m-d')) : [selected|date('Y-m-d')]) -%}
{%- set _formatted = _selected|map(date => date|format_date(dateFormat: dateStyle, locale: locale))|join(separator) -%}
{%- do provide('popover.open', open) -%}
{%- do provide('datePicker.empty', _selected is empty) -%}
{%- do provide('datePicker.formatted', _formatted) -%}
<div
    data-slot="date-picker"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-empty="{{ _selected is empty ? 'true' : 'false' }}"
    data-popover-open-value="{{ open ? 'true' : 'false' }}"
    data-date-picker-locale-value="{{ locale }}"
    data-date-picker-date-style-value="{{ dateStyle }}"
    data-date-picker-separator-value="{{ separator }}"
    data-date-picker-close-on-select-value="{{ closeOnSelect ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'group/popover group/date-picker relative inline-block'|tailwind_classes,
        'data-controller': 'popover date-picker',
        'data-action': [
            'popover:open@window->popover#handleGroupOpen',
            'click@window->popover#handleOutsideClick',
            'keydown.esc@window->popover#handleEscape',
            'calendar:select->date-picker#select',
        ]|join(' ')|html_attr_type('sst'),
    }) }}
>
    {##- A `DatePicker:Trigger` and a `DatePicker:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DatePicker/Content.html.twig
{%- props
    ## 'top'|'right'|'bottom'|'left' Which side of the trigger the calendar appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the calendar along the cross axis.
    align = 'start'
-%}
<twig:Popover:Content
    side="{{ side }}"
    align="{{ align }}"
    {{ ...attributes.defaults({class: 'w-auto overflow-hidden p-0'|tailwind_classes}) }}
>
    {##- The popover body, typically a `Calendar`. -#}
    {{- block(outerBlocks.content) -}}
</twig:Popover:Content>
templates/components/DatePicker/Input.html.twig
{%- set _datePicker_formatted = inject('datePicker.formatted', '') -%}
{%- set date_picker_input_attrs = {
    'data-date-picker-target': 'input',
    'data-action': 'input->date-picker#parseInput keydown.down->date-picker#open'|html_attr_type('sst'),
    value: _datePicker_formatted,
    autocomplete: 'off',
} -%}
{##- The text input (e.g., an `Input` or an `InputGroup:Input`) mirroring the selection. -#}
{%- block content %}{% endblock -%}
templates/components/DatePicker/Trigger.html.twig
{%- set _popover_open = inject('popover.open', false) -%}
{%- set _datePicker_empty = inject('datePicker.empty', true) -%}
{%- set date_picker_trigger_attrs = {
    'data-slot': 'date-picker-trigger',
    'data-popover-target': 'trigger',
    'data-date-picker-target': 'trigger',
    'data-action': 'click->popover#toggle'|html_attr_type('sst'),
    'aria-haspopup': 'dialog',
    'aria-expanded': _popover_open ? 'true' : 'false',
    'data-state': _popover_open ? 'open' : 'closed',
    'data-empty': _datePicker_empty ? 'true' : 'false',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the date picker when clicked. -#}
{%- block content %}{% endblock -%}
templates/components/DatePicker/Value.html.twig
{%- props
    ## string The text shown while nothing is selected.
    placeholder = 'Pick a date'
-%}
{%- set _datePicker_formatted = inject('datePicker.formatted', '') -%}
{%- set _datePicker_empty = inject('datePicker.empty', true) -%}
<span
    data-slot="date-picker-value"
    data-date-picker-target="value"
    data-empty="{{ _datePicker_empty ? 'true' : 'false' }}"
    data-placeholder="{{ placeholder }}"
    {{ attributes }}
>
    {{- _datePicker_empty ? placeholder : _datePicker_formatted -}}
</span>
assets/controllers/popover_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['trigger', 'content'];

    static values = {
        open: { type: Boolean, default: false },
        name: { type: String, default: '' },
    };

    #connected = false;

    connect() {
        this.updateState();
        this.#connected = true;
    }

    toggle(event) {
        event?.preventDefault();
        this.openValue = !this.openValue;
    }

    close() {
        this.openValue = false;
    }

    openValueChanged() {
        this.updateState();

        if (!this.openValue) {
            return;
        }

        if (this.nameValue) {
            window.dispatchEvent(
                new CustomEvent('popover:open', {
                    detail: { name: this.nameValue, source: this.element },
                })
            );
        }

        // Skip on initial render so an `open` popover does not steal focus on page load.
        if (this.#connected) {
            this.#focusContent();
        }
    }

    handleGroupOpen(event) {
        if (!this.nameValue || !this.openValue) {
            return;
        }
        if (event.detail.name !== this.nameValue || event.detail.source === this.element) {
            return;
        }
        this.openValue = false;
    }

    handleOutsideClick(event) {
        if (!this.openValue || this.element.contains(event.target)) {
            return;
        }
        this.openValue = false;
    }

    handleEscape(event) {
        if (!this.openValue || event.key !== 'Escape') {
            return;
        }
        this.openValue = false;
        this.triggerTargets[0]?.focus();
    }

    updateState() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        for (const trigger of this.triggerTargets) {
            trigger.setAttribute('aria-expanded', String(open));
            trigger.dataset.state = state;
        }

        for (const content of this.contentTargets) {
            content.dataset.state = state;
            content.setAttribute('aria-hidden', String(!open));
        }
    }

    #focusContent() {
        const content = this.contentTargets[0];
        const focusable =
            content?.querySelector('[autofocus]:not([disabled])') ??
            content?.querySelector(
                'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
            );
        if (!focusable) {
            return;
        }

        // Wait two frames: `visibility` is transitioned, so the content computes as
        // `visibility: hidden` (not focusable) on the first frame after opening and is
        // only `visible` from the next one.
        requestAnimationFrame(() => requestAnimationFrame(() => focusable.focus()));
    }
}
templates/components/Popover.html.twig
{%- props
    ## string|null Group name shared by mutually exclusive popovers, so only one is open at a time.
    name = null,
    ## boolean Whether the popover is open on initial render.
    open = false
-%}
{%- do provide('popover.open', open) -%}
<div
    data-slot="popover"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-popover-open-value="{{ open ? 'true' : 'false' }}"
    data-popover-name-value="{{ name }}"
    {{ attributes.defaults({
        class: 'group/popover relative inline-block'|tailwind_classes,
        'data-controller': 'popover',
        'data-action': [
            'popover:open@window->popover#handleGroupOpen',
            'click@window->popover#handleOutsideClick',
            'keydown.esc@window->popover#handleEscape',
        ]|join(' ')|html_attr_type('sst'),
    }) }}
>
    {##- A `Popover:Trigger` and a `Popover:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Popover/Content.html.twig
{%- props
    ## 'top'|'right'|'bottom'|'left' Which side of the trigger the content appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the content along the cross axis.
    align = 'center'
-%}
{%- set _popover_open = inject('popover.open', false) -%}
{%- set style = html_cva(
    base: 'invisible scale-95 opacity-0 transition-[opacity,scale,visibility] duration-150 ease-out group-data-[state=open]/popover:visible group-data-[state=open]/popover:scale-100 group-data-[state=open]/popover:opacity-100 absolute z-50 w-72 rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md outline-none',
    variants: {
        side: {
            top: 'bottom-full mb-2',
            bottom: 'top-full mt-2',
            left: 'right-full top-1/2 -translate-y-1/2 mr-2 origin-right',
            right: 'left-full top-1/2 -translate-y-1/2 ml-2 origin-left',
        },
    },
    compound_variants: [
        {side: ['top'], align: ['start'], class: 'start-0 ltr:origin-bottom-left rtl:origin-bottom-right'},
        {side: ['top'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-bottom'},
        {side: ['top'], align: ['end'], class: 'end-0 ltr:origin-bottom-right rtl:origin-bottom-left'},
        {side: ['bottom'], align: ['start'], class: 'start-0 ltr:origin-top-left rtl:origin-top-right'},
        {side: ['bottom'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-top'},
        {side: ['bottom'], align: ['end'], class: 'end-0 ltr:origin-top-right rtl:origin-top-left'},
    ],
) -%}
<div
    role="dialog"
    data-slot="popover-content"
    data-popover-target="content"
    data-side="{{ side }}"
    data-align="{{ align }}"
    data-state="{{ _popover_open ? 'open' : 'closed' }}"
    aria-hidden="{{ _popover_open ? 'false' : 'true' }}"
    {{ attributes.defaults({
        class: style.apply({side: side, align: align})|tailwind_classes,
    }) }}
>
    {##- The content revealed when the popover is open. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Popover/Trigger.html.twig
{%- set _popover_open = inject('popover.open', false) -%}
{%- set popover_trigger_attrs = {
    'data-slot': 'popover-trigger',
    'data-popover-target': 'trigger',
    'data-action': 'click->popover#toggle'|html_attr_type('sst'),
    'aria-haspopup': 'dialog',
    'aria-expanded': _popover_open ? 'true' : 'false',
    'data-state': _popover_open ? 'open' : 'closed',
} -%}
{##- The clickable trigger (e.g., a `Button`) that opens the popover. -#}
{%- block content %}{% endblock -%}
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:DatePicker
    selected="2026-03-15"
    dateStyle="full | long | medium | short"
    :closeOnSelect="true"
>
    <twig:DatePicker:Trigger>
        <twig:Button variant="outline" {{ ...date_picker_trigger_attrs }}>
            <twig:DatePicker:Value placeholder="Pick a date" />
        </twig:Button>
    </twig:DatePicker:Trigger>
    <twig:DatePicker:Content side="top | right | bottom | left" align="start | center | end">
        <twig:Calendar mode="single" name="date" selected="2026-03-15" />
    </twig:DatePicker:Content>
</twig:DatePicker>

There is no date picker component upstream: it is a composition, and so is this recipe. DatePicker hosts the popover and date-picker controllers on a single element, DatePicker:Trigger and DatePicker:Input expose an attributes bag to spread onto your own element, and the selection itself belongs to the nested Calendar.

Dates are exchanged as Y-m-d strings, as in the Calendar. Set selected on both components: the Calendar owns the grid, while DatePicker needs it to render the formatted selection on the trigger server-side. Set name on the Calendar to mirror the selection into hidden inputs and submit it with a form.

Picking a day formats it with dateStyle and locale, writes it into every DatePicker:Value and DatePicker:Input, and closes the popover once the selection is complete — in range mode, only after both ends are set. Pass :closeOnSelect="false" to keep it open.

Examples

Basic

A date picker inside a Field, so it is labelled like any other form control.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
    <twig:Field class="w-44">
        <twig:Field:Label for="date-picker-basic">Date</twig:Field:Label>
        <twig:DatePicker>
            <twig:DatePicker:Trigger>
                <twig:Button
                    id="date-picker-basic"
                    variant="outline"
                    class="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
                    {{ ...date_picker_trigger_attrs }}
                >
                    <twig:DatePicker:Value placeholder="Pick a date" />
                </twig:Button>
            </twig:DatePicker:Trigger>
            <twig:DatePicker:Content>
                <twig:Calendar mode="single" today="2026-03-15" />
            </twig:DatePicker:Content>
        </twig:DatePicker>
    </twig:Field>
</div>

Range Picker

Set mode="range" on the Calendar and pass both ends to selected. The two dates are joined by separator, and the popover only closes once the end date is picked.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 440px">
    <twig:Field class="w-60">
        <twig:Field:Label for="date-picker-range">Date Picker Range</twig:Field:Label>
        <twig:DatePicker :selected="['2026-01-20', '2026-02-09']" dateStyle="medium">
            <twig:DatePicker:Trigger>
                <twig:Button
                    id="date-picker-range"
                    variant="outline"
                    class="w-full justify-start px-2.5 font-normal data-[empty=true]:text-muted-foreground"
                    {{ ...date_picker_trigger_attrs }}
                >
                    <twig:ux:icon name="lucide:calendar" data-icon="inline-start" />
                    <twig:DatePicker:Value placeholder="Pick a date" />
                </twig:Button>
            </twig:DatePicker:Trigger>
            <twig:DatePicker:Content>
                <twig:Calendar
                    mode="range"
                    :selected="['2026-01-20', '2026-02-09']"
                    :numberOfMonths="2"
                    today="2026-03-15"
                />
            </twig:DatePicker:Content>
        </twig:DatePicker>
    </twig:Field>
</div>

Date of Birth

Pair captionLayout="dropdown" with startMonth and endMonth so a distant year is a couple of clicks away rather than a hundred.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
    <twig:Field class="w-44">
        <twig:Field:Label for="date-picker-dob">Date of birth</twig:Field:Label>
        <twig:DatePicker dateStyle="medium">
            <twig:DatePicker:Trigger>
                <twig:Button
                    id="date-picker-dob"
                    variant="outline"
                    class="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
                    {{ ...date_picker_trigger_attrs }}
                >
                    <twig:DatePicker:Value placeholder="Select date" />
                </twig:Button>
            </twig:DatePicker:Trigger>
            <twig:DatePicker:Content>
                <twig:Calendar
                    mode="single"
                    captionLayout="dropdown"
                    startMonth="1926-01-01"
                    endMonth="2026-12-01"
                    today="2026-03-15"
                />
            </twig:DatePicker:Content>
        </twig:DatePicker>
    </twig:Field>
</div>

Input

Spread date_picker_input_attrs onto a text input to keep it in sync with the calendar in both directions: it's rendered server-side already holding the current selection formatted, picking a day writes the formatted date back into it, and typing a date moves the calendar to match. opens the popover from the input.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 420px">
    <twig:Field class="w-56">
        <twig:Field:Label for="date-picker-input">Subscription Date</twig:Field:Label>
        <twig:DatePicker selected="2026-06-01">
            <twig:InputGroup>
                <twig:DatePicker:Input>
                    <twig:InputGroup:Input
                        id="date-picker-input"
                        placeholder="June 1, 2026"
                        {{ ...date_picker_input_attrs }}
                    />
                </twig:DatePicker:Input>
                <twig:InputGroup:Addon align="inline-end">
                    <twig:DatePicker:Trigger>
                        <twig:InputGroup:Button
                            variant="ghost"
                            size="icon-xs"
                            {{ ...date_picker_trigger_attrs }}
                        >
                            <twig:ux:icon name="lucide:calendar" />
                            <span class="sr-only">Select date</span>
                        </twig:InputGroup:Button>
                    </twig:DatePicker:Trigger>
                </twig:InputGroup:Addon>
            </twig:InputGroup>
            <twig:DatePicker:Content align="end">
                <twig:Calendar mode="single" selected="2026-06-01" today="2026-03-15" />
            </twig:DatePicker:Content>
        </twig:DatePicker>
    </twig:Field>
</div>

Time Picker

The date picker only handles the date. Pair it with a native <twig:Input type="time" /> to collect a time alongside it.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 420px">
    <twig:Field orientation="horizontal" class="w-auto items-start gap-4">
        <twig:Field class="w-40">
            <twig:Field:Label for="date-picker-time">Date</twig:Field:Label>
            <twig:DatePicker dateStyle="medium">
                <twig:DatePicker:Trigger>
                    <twig:Button
                        id="date-picker-time"
                        variant="outline"
                        class="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"
                        {{ ...date_picker_trigger_attrs }}
                    >
                        <twig:DatePicker:Value placeholder="Select date" />
                        <twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
                    </twig:Button>
                </twig:DatePicker:Trigger>
                <twig:DatePicker:Content>
                    <twig:Calendar mode="single" captionLayout="dropdown" today="2026-03-15" />
                </twig:DatePicker:Content>
            </twig:DatePicker>
        </twig:Field>
        <twig:Field class="w-36">
            <twig:Field:Label for="time-picker">Time</twig:Field:Label>
            <twig:Input
                type="time"
                id="time-picker"
                step="1"
                value="10:30:00"
                class="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
            />
        </twig:Field>
    </twig:Field>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element. Pass the same locale to the DatePicker and to the Calendar, so the trigger label and the grid are formatted for the same language, and pin the numbering system with the -u-nu- extension as the Calendar documents.

100%
Loading...
<div class="flex flex-col items-center gap-6 pt-6" style="min-height: 500px">
    {# Arabic #}
    <twig:DatePicker dir="rtl" locale="ar-u-nu-latn" selected="2026-03-15">
        <twig:DatePicker:Trigger>
            <twig:Button
                variant="outline"
                class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
                {{ ...date_picker_trigger_attrs }}
            >
                <twig:DatePicker:Value placeholder="اختر تاريخًا" />
                <twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
            </twig:Button>
        </twig:DatePicker:Trigger>
        <twig:DatePicker:Content>
            <twig:Calendar
                locale="ar-u-nu-latn"
                weekStartsOn="6"
                mode="single"
                selected="2026-03-15"
                today="2026-03-15"
            />
        </twig:DatePicker:Content>
    </twig:DatePicker>

    {# Hebrew #}
    <twig:DatePicker dir="rtl" locale="he-u-nu-latn" selected="2026-03-15">
        <twig:DatePicker:Trigger>
            <twig:Button
                variant="outline"
                class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
                {{ ...date_picker_trigger_attrs }}
            >
                <twig:DatePicker:Value placeholder="בחר תאריך" />
                <twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
            </twig:Button>
        </twig:DatePicker:Trigger>
        <twig:DatePicker:Content>
            <twig:Calendar
                locale="he-u-nu-latn"
                weekStartsOn="0"
                mode="single"
                selected="2026-03-15"
                today="2026-03-15"
            />
        </twig:DatePicker:Content>
    </twig:DatePicker>
</div>

Natural Language Input

DatePicker:Input reads what is typed with Date.parse(), so it accepts Y-m-d and the other formats the browser recognises, but not phrases such as tomorrow or in 2 days. Parsing those is a library's job, and chrono-node does it, in several languages.

Install it, either with importmap:require for AssetMapper, or npm for Webpack Encore:

# With AssetMapper
php bin/console importmap:require chrono-node

# With npm
npm install chrono-node

Then rewrite #parse() in date_picker_controller.js, the single place where typed text becomes a Y-m-d string:

import { Controller } from '@hotwired/stimulus';
import * as chrono from 'chrono-node';

export default class extends Controller {
    // ...

    #parse(text) {
        const value = text.trim();
        if ('' === value) {
            return null;
        }

        // `forwardDate` resolves an ambiguous phrase such as `friday` to the next one.
        const parsed = chrono.parseDate(value, undefined, { forwardDate: true });

        return parsed ? this.#toDateString(parsed) : null;
    }
}

chrono.parseDate() returns a local Date, or null when it finds no date in the text, and #toDateString() already turns that Date into the Y-m-d string the Calendar expects. Reach for a localized parser, chrono.fr, chrono.de, chrono.es, chrono.ja, to match the language of your application, and chrono.en.GB to read 25/12/2026 as day-first.

Accessibility

  • DatePicker:Trigger exposes aria-haspopup="dialog" and an aria-expanded reflecting whether the calendar is open, plus a data-empty flag while nothing is selected.
  • DatePicker:Content wraps a Popover:Content, which renders role="dialog" with an aria-hidden mirroring the open state.
  • Opening the picker moves focus into the popover: to the element carrying [autofocus] if there is one, otherwise the first focusable control, which for a calendar is its focusable day.
  • Escape closes the picker and returns focus to the trigger. A click outside closes it too, but focus simply leaving it does not, so keyboard users should close with Escape.
  • DatePicker:Value renders the placeholder text while nothing is selected, so a trigger built from it always has a visible and accessible name.
  • DatePicker:Input is a real text input, so give it a Field:Label. Typing a date moves the calendar to that month, and ArrowDown opens the picker.
  • Inside the popover the nested Calendar keeps its own keyboard handling: one day in the tab order, arrow keys, Home and End, PageUp and PageDown.

API Reference

<twig:DatePicker>

Prop Type Default
selected 
string|array<string>|null null
locale 
string|null null
dateStyle 
'full'|'long'|'medium'|'short' 'long'
separator 
string ' - '
closeOnSelect 
boolean true
open 
boolean false
Block Description
content A DatePicker:Trigger and a DatePicker:Content.

<twig:DatePicker:Content>

Prop Type Default
side 
'top'|'right'|'bottom'|'left' 'bottom'
align 
'start'|'center'|'end' 'start'
Block Description
content The popover body, typically a Calendar.

<twig:DatePicker:Input>

Block Description
content The text input (e.g., an Input or an InputGroup:Input) mirroring the selection.

<twig:DatePicker:Trigger>

Block Description
content The trigger element (e.g., a Button) that opens the date picker when clicked.

<twig:DatePicker:Value>

Prop Type Default
placeholder 
string 'Pick a date'

data-controller="date-picker"

Value Type Default
data-date-picker-locale-value 
String -
data-date-picker-date-style-value 
String 'long'
data-date-picker-separator-value 
String ' - '
data-date-picker-close-on-select-value 
Boolean true
Target Description
trigger The element opening the popover, flagged while nothing is selected.
value The element displaying the formatted selection.
input The text input mirroring the selection.
Action Description
select Reflects a calendar selection on the trigger, the value and the input.
parseInput Moves the calendar to the date typed in the input.
open Opens the popover.