View as Markdown

Dropdown Menu

A menu triggered by a button, providing a list of actions or links.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 460px">
    <twig:DropdownMenu id="demo">
        <twig:DropdownMenu:Trigger>
            <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
        </twig:DropdownMenu:Trigger>
        <twig:DropdownMenu:Content class="w-56">
            <twig:DropdownMenu:Label>My Account</twig:DropdownMenu:Label>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Group>
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:user" class="size-4" />
                    Profile
                    <twig:DropdownMenu:Shortcut>⇧⌘P</twig:DropdownMenu:Shortcut>
                </twig:DropdownMenu:Item>
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:credit-card" class="size-4" />
                    Billing
                    <twig:DropdownMenu:Shortcut>⌘B</twig:DropdownMenu:Shortcut>
                </twig:DropdownMenu:Item>
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:settings" class="size-4" />
                    Settings
                    <twig:DropdownMenu:Shortcut>⌘S</twig:DropdownMenu:Shortcut>
                </twig:DropdownMenu:Item>
            </twig:DropdownMenu:Group>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Group>
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:users" class="size-4" />
                    Team
                </twig:DropdownMenu:Item>
                <twig:DropdownMenu:Sub>
                    <twig:DropdownMenu:SubTrigger>
                        <twig:ux:icon name="lucide:user-plus" class="size-4" />
                        Invite users
                    </twig:DropdownMenu:SubTrigger>
                    <twig:DropdownMenu:SubContent>
                        <twig:DropdownMenu:Item>
                            <twig:ux:icon name="lucide:mail" class="size-4" />
                            Email
                        </twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>
                            <twig:ux:icon name="lucide:message-square" class="size-4" />
                            Message
                        </twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Separator />
                        <twig:DropdownMenu:Item>
                            <twig:ux:icon name="lucide:ellipsis" class="size-4" />
                            More...
                        </twig:DropdownMenu:Item>
                    </twig:DropdownMenu:SubContent>
                </twig:DropdownMenu:Sub>
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:plus" class="size-4" />
                    New Team
                    <twig:DropdownMenu:Shortcut>⌘T</twig:DropdownMenu:Shortcut>
                </twig:DropdownMenu:Item>
            </twig:DropdownMenu:Group>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Item disabled>
                <twig:ux:icon name="lucide:cloud" class="size-4" />
                API
            </twig:DropdownMenu:Item>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Item>
                <twig:ux:icon name="lucide:log-out" class="size-4" />
                Log out
                <twig:DropdownMenu:Shortcut>⇧⌘Q</twig:DropdownMenu:Shortcut>
            </twig:DropdownMenu:Item>
        </twig:DropdownMenu:Content>
    </twig:DropdownMenu>
</div>

Installation

php bin/console ux:install dropdown-menu --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 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/dropdown_menu_controller.js
import { Controller } from '@hotwired/stimulus';

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

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

    connect() {
        this._onOutsideClick = this._onOutsideClick.bind(this);
        this._setState(this.openValue);
        if (this.openValue) {
            this.open({ focus: false });
        }
    }

    disconnect() {
        document.removeEventListener('click', this._onOutsideClick, true);
    }

    toggle(event) {
        event?.preventDefault();
        if (this.element.dataset.state === 'open') {
            this.close();
        } else {
            this.open();
        }
    }

    open({ focus = 'first' } = {}) {
        this._setState(true);
        document.addEventListener('click', this._onOutsideClick, true);

        if (focus) {
            // Wait two frames: `visibility` is transitioned, so the content is
            // `visibility: hidden` (its items not focusable) on the first frame.
            requestAnimationFrame(() =>
                requestAnimationFrame(() => (focus === 'last' ? this._focusLastItem() : this._focusFirstItem()))
            );
        }
    }

    close({ focus = true } = {}) {
        this._setState(false);
        document.removeEventListener('click', this._onOutsideClick, true);

        if (focus && this.hasTriggerTarget) {
            this.triggerTarget.focus();
        }
    }

    closeFromItem(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        if (item.dataset.closeOnSelect !== 'false') {
            this.close();
        }
    }

    toggleCheckbox(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        const checked = item.getAttribute('aria-checked') !== 'true';
        item.setAttribute('aria-checked', String(checked));

        const indicator = item.querySelector('[data-checkbox-indicator]');
        if (indicator) {
            indicator.hidden = !checked;
        }

        if (item.dataset.closeOnSelect === 'true') {
            this.close();
        }
    }

    selectRadio(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        const group = item.closest('[data-slot="dropdown-menu-radio-group"]');
        if (!group) {
            return;
        }

        group.dataset.value = item.dataset.value;

        for (const radio of group.querySelectorAll('[role="menuitemradio"]')) {
            const selected = radio.dataset.value === item.dataset.value;
            radio.setAttribute('aria-checked', String(selected));

            const indicator = radio.querySelector('[data-radio-indicator]');
            if (indicator) {
                indicator.hidden = !selected;
            }
        }

        if (item.dataset.closeOnSelect === 'true') {
            this.close();
        }
    }

    onTriggerKeydown(event) {
        if (event.key === 'ArrowDown') {
            event.preventDefault();
            this.open({ focus: 'first' });
        } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            this.open({ focus: 'last' });
        }
    }

    onContentKeydown(event) {
        switch (event.key) {
            case 'Escape':
                event.preventDefault();
                this.close();
                break;
            case 'ArrowDown':
                event.preventDefault();
                this._focusNextItem();
                break;
            case 'ArrowUp':
                event.preventDefault();
                this._focusPrevItem();
                break;
            case 'Home':
                event.preventDefault();
                this._focusFirstItem();
                break;
            case 'End':
                event.preventDefault();
                this._focusLastItem();
                break;
            case 'Tab':
                this.close({ focus: false });
                break;
        }
    }

    _setState(isOpen) {
        const state = isOpen ? 'open' : 'closed';
        this.element.dataset.state = state;

        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
            this.triggerTarget.setAttribute('aria-expanded', String(isOpen));
        }
        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;
        }
    }

    _onOutsideClick(event) {
        if (!this.element.contains(event.target)) {
            this.close({ focus: false });
        }
    }

    _isItemDisabled(item) {
        return (
            !item ||
            item.getAttribute('aria-disabled') === 'true' ||
            item.dataset.disabled === 'true' ||
            item.hasAttribute('disabled')
        );
    }

    _enabledItems() {
        // Exclude items nested in a sub-menu: they are not part of this menu's roving focus.
        return this.itemTargets.filter(
            (item) => !this._isItemDisabled(item) && !item.closest('[data-slot="dropdown-menu-sub-content"]')
        );
    }

    _focusFirstItem() {
        this._enabledItems()[0]?.focus();
    }

    _focusLastItem() {
        const items = this._enabledItems();
        items[items.length - 1]?.focus();
    }

    _focusNextItem() {
        const items = this._enabledItems();
        if (items.length === 0) return;
        const index = items.indexOf(document.activeElement);
        items[index === -1 ? 0 : (index + 1) % items.length].focus();
    }

    _focusPrevItem() {
        const items = this._enabledItems();
        if (items.length === 0) return;
        const index = items.indexOf(document.activeElement);
        items[index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length].focus();
    }
}
assets/controllers/dropdown_menu_sub_controller.js
import { Controller } from '@hotwired/stimulus';

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

    static values = {
        openDelay: { type: Number, default: 0 },
        closeDelay: { type: Number, default: 0 },
    };

    connect() {
        this.openTimeout = null;
        this.closeTimeout = null;
        this.#setState('closed');
    }

    disconnect() {
        this.#clearTimeouts();
    }

    show() {
        this.#clearTimeouts();
        this.openTimeout = setTimeout(() => {
            this.#setState('open');
            this.openTimeout = null;
        }, this.openDelayValue);
    }

    hide() {
        this.#clearTimeouts();
        this.closeTimeout = setTimeout(() => {
            this.#setState('closed');
            this.closeTimeout = null;
        }, this.closeDelayValue);
    }

    // State lives on the trigger and content directly (not a parent group) so nested
    // sub-menus each react only to their own state.
    #setState(state) {
        this.element.dataset.state = state;
        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
        }
        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;
        }
    }

    #clearTimeouts() {
        if (this.openTimeout) {
            clearTimeout(this.openTimeout);
            this.openTimeout = null;
        }
        if (this.closeTimeout) {
            clearTimeout(this.closeTimeout);
            this.closeTimeout = null;
        }
    }
}
templates/components/DropdownMenu.html.twig
{# @prop id string Unique identifier used to generate the internal DropdownMenu IDs. #}
{# @prop open boolean Whether the menu is open on initial render. #}
{# @prop side 'top'|'right'|'bottom'|'left' Which side of the trigger the menu appears on. #}
{# @prop align 'start'|'center'|'end' Alignment of the menu along the cross axis. #}
{# @block content A `DropdownMenu:Trigger` and a `DropdownMenu:Content`. #}
{%- props id, open = false, side = 'bottom', align = 'start' -%}
{%- do provide('dropdownMenu.open', open) -%}
{%- do provide('dropdownMenu.side', side) -%}
{%- do provide('dropdownMenu.align', align) -%}
{%- do provide('dropdownMenu.triggerId', 'dropdown-menu-' ~ id ~ '-trigger') -%}
{%- do provide('dropdownMenu.contentId', 'dropdown-menu-' ~ id ~ '-content') -%}
<div
    data-slot="dropdown-menu"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-dropdown-menu-open-value="{{ open ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'group/dropdown-menu relative inline-block'|tailwind_classes,
        'data-controller': 'dropdown-menu',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/CheckboxItem.html.twig
{# @prop checked boolean Whether the checkbox is initially checked. #}
{# @prop disabled boolean Whether the item is disabled. #}
{# @prop closeOnSelect boolean Whether selecting the item closes the menu. #}
{# @block content The label text. #}
{%- props checked = false, disabled = false, closeOnSelect = false -%}
<button
    type="button"
    role="menuitemcheckbox"
    tabindex="-1"
    data-slot="dropdown-menu-checkbox-item"
    data-dropdown-menu-target="item"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-checked="{{ checked ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {%- if disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0'|tailwind_classes,
        'data-action': 'click->dropdown-menu#toggleCheckbox',
    }) }}
>
    <span class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center" data-checkbox-indicator {% if not checked %}hidden{% endif %}>
        <twig:ux:icon name="lucide:check" class="size-4" aria-hidden="true" />
    </span>
    {%- block content %}{% endblock -%}
</button>
templates/components/DropdownMenu/Content.html.twig
{# @block content The menu items, e.g. `DropdownMenu:Item`, `DropdownMenu:Separator`. #}
{%- set _dropdownMenu_open = inject('dropdownMenu.open', false) -%}
{%- set _dropdownMenu_side = inject('dropdownMenu.side', 'bottom') -%}
{%- set _dropdownMenu_align = inject('dropdownMenu.align', 'start') -%}
{%- set _dropdownMenu_contentId = inject('dropdownMenu.contentId', null) -%}
{%- set _dropdownMenu_triggerId = inject('dropdownMenu.triggerId', null) -%}
{%- set style = html_cva(
    base: 'invisible scale-95 opacity-0 transition-[opacity,scale,visibility] duration-150 ease-out group-data-[state=open]/dropdown-menu:visible group-data-[state=open]/dropdown-menu:scale-100 group-data-[state=open]/dropdown-menu:opacity-100 absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 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
    id="{{ _dropdownMenu_contentId }}"
    role="menu"
    tabindex="-1"
    aria-labelledby="{{ _dropdownMenu_triggerId }}"
    data-slot="dropdown-menu-content"
    data-dropdown-menu-target="content"
    data-state="{{ _dropdownMenu_open ? 'open' : 'closed' }}"
    data-side="{{ _dropdownMenu_side }}"
    data-align="{{ _dropdownMenu_align }}"
    data-action="keydown->dropdown-menu#onContentKeydown"
    {{ attributes.defaults({
        class: style.apply({side: _dropdownMenu_side, align: _dropdownMenu_align})|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Group.html.twig
{# @block content One or more `DropdownMenu:Item` components. #}
<div role="group" data-slot="dropdown-menu-group" {{ attributes }}>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Item.html.twig
{# @prop as 'button'|'a'|'div' The HTML tag to render. #}
{# @prop disabled boolean Whether the item is disabled. #}
{# @prop inset boolean Whether the item is inset with extra start padding. #}
{# @prop closeOnSelect boolean Whether selecting the item closes the menu. #}
{# @block content The item label. #}
{%- props as = 'button', disabled = false, inset = false, closeOnSelect = true -%}
{%- set style = html_cva(
    base: 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
    variants: {
        inset: {
            true: 'ps-8',
        },
    },
) -%}
<{{ as }}
    role="menuitem"
    tabindex="-1"
    data-slot="dropdown-menu-item"
    data-dropdown-menu-target="item"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {% if as == 'button' %}type="button"{% endif %}
    {%- if as == 'button' and disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: style.apply({inset: inset ? 'true' : 'false'})|tailwind_classes,
        'data-action': 'click->dropdown-menu#closeFromItem',
    }) }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/DropdownMenu/Label.html.twig
{# @prop inset boolean Whether the label is inset with extra start padding. #}
{# @block content The label text. #}
{%- props inset = false -%}
{%- set style = html_cva(
    base: 'px-2 py-1.5 text-sm font-semibold',
    variants: {
        inset: {
            true: 'ps-8',
        },
    },
) -%}
<div
    data-slot="dropdown-menu-label"
    {{ attributes.defaults({
        class: style.apply({inset: inset ? 'true' : 'false'})|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioGroup.html.twig
{# @prop value string|null The currently selected value. #}
{# @block content One or more `DropdownMenu:RadioItem` components. #}
{%- props value = null -%}
<div
    role="group"
    data-slot="dropdown-menu-radio-group"
    data-value="{{ value }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioItem.html.twig
{# @prop value string The value submitted when this radio is selected. #}
{# @prop checked boolean Whether this radio is initially checked. #}
{# @prop disabled boolean Whether the item is disabled. #}
{# @prop closeOnSelect boolean Whether selecting the item closes the menu. #}
{# @block content The label text. #}
{%- props value, checked = false, disabled = false, closeOnSelect = false -%}
<button
    type="button"
    role="menuitemradio"
    tabindex="-1"
    data-slot="dropdown-menu-radio-item"
    data-dropdown-menu-target="item"
    data-value="{{ value }}"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-checked="{{ checked ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {%- if disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0'|tailwind_classes,
        'data-action': 'click->dropdown-menu#selectRadio',
    }) }}
>
    <span class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center" data-radio-indicator {% if not checked %}hidden{% endif %}>
        <twig:ux:icon name="lucide:circle" class="size-2 fill-current" aria-hidden="true" />
    </span>
    {%- block content %}{% endblock -%}
</button>
templates/components/DropdownMenu/Separator.html.twig
<div
    role="separator"
    aria-orientation="horizontal"
    data-slot="dropdown-menu-separator"
    {{ attributes.defaults({
        class: '-mx-1 my-1 h-px bg-border'|tailwind_classes,
    }) }}
></div>
templates/components/DropdownMenu/Shortcut.html.twig
{# @block content The shortcut label, e.g. "⌘K". #}
<span
    data-slot="dropdown-menu-shortcut"
    {{ attributes.defaults({
        class: 'ms-auto text-xs tracking-widest text-muted-foreground'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</span>
templates/components/DropdownMenu/Sub.html.twig
{# @prop openDelay number Delay in milliseconds before showing the sub-menu. #}
{# @prop closeDelay number Delay in milliseconds before hiding the sub-menu. #}
{# @block content A `DropdownMenu:SubTrigger` and a `DropdownMenu:SubContent`. #}
{%- props openDelay = 0, closeDelay = 0 -%}
<div
    data-slot="dropdown-menu-sub"
    data-dropdown-menu-sub-open-delay-value="{{ openDelay }}"
    data-dropdown-menu-sub-close-delay-value="{{ closeDelay }}"
    {{ attributes.defaults({
        class: 'relative'|tailwind_classes,
        'data-controller': 'dropdown-menu-sub',
        'data-action': 'mouseenter->dropdown-menu-sub#show mouseleave->dropdown-menu-sub#hide',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubContent.html.twig
{# @block content The sub-menu items, e.g. `DropdownMenu:Item`. #}
<div
    role="menu"
    data-slot="dropdown-menu-sub-content"
    data-dropdown-menu-sub-target="content"
    data-state="closed"
    {{ attributes.defaults({
        class: 'invisible opacity-0 transition-[opacity,translate,visibility] duration-150 ease-out data-[state=open]:visible data-[state=open]:opacity-100 data-[state=open]:translate-x-0 data-[state=closed]:ltr:-translate-x-2 data-[state=closed]:rtl:translate-x-2 absolute start-full top-0 z-50 ms-1 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubTrigger.html.twig
{# @prop inset boolean Whether the trigger is inset with extra start padding. #}
{# @block content The sub-menu trigger label. #}
{%- props inset = false -%}
{%- set style = html_cva(
    base: 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
    variants: {
        inset: {
            true: 'ps-8',
        },
    },
) -%}
<button
    type="button"
    role="menuitem"
    tabindex="-1"
    aria-haspopup="menu"
    data-slot="dropdown-menu-sub-trigger"
    data-dropdown-menu-target="item"
    data-dropdown-menu-sub-target="trigger"
    data-state="closed"
    {{ attributes.defaults({
        class: style.apply({inset: inset ? 'true' : 'false'})|tailwind_classes,
        'data-action': 'mouseenter->dropdown-menu-sub#show focus->dropdown-menu-sub#show blur->dropdown-menu-sub#hide',
    }) }}
>
    {%- block content %}{% endblock -%}
    <twig:ux:icon name="lucide:chevron-right" class="ms-auto size-4 rtl:rotate-180" aria-hidden="true" />
</button>
templates/components/DropdownMenu/Trigger.html.twig
{# @block content The trigger label (e.g., a `Button`) that opens the menu on click. #}
{%- set _dropdownMenu_triggerId = inject('dropdownMenu.triggerId', null) -%}
{%- set _dropdownMenu_contentId = inject('dropdownMenu.contentId', null) -%}
{%- set dropdown_menu_trigger_attrs = {
    id: _dropdownMenu_triggerId,
    'data-slot': 'dropdown-menu-trigger',
    'data-dropdown-menu-target': 'trigger',
    'aria-haspopup': 'menu',
    'aria-controls': _dropdownMenu_contentId,
    'aria-expanded': 'false',
    'data-state': 'closed',
    'data-action': 'click->dropdown-menu#toggle keydown->dropdown-menu#onTriggerKeydown'|html_attr_type('sst'),
} -%}
{%- block content %}{% endblock -%}

Usage

<twig:DropdownMenu id="menu" side="bottom" align="start">
    <twig:DropdownMenu:Trigger>
        <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
    </twig:DropdownMenu:Trigger>
    <twig:DropdownMenu:Content class="w-40">
        <twig:DropdownMenu:Item>Profile</twig:DropdownMenu:Item>
        <twig:DropdownMenu:Item>Settings</twig:DropdownMenu:Item>
        <twig:DropdownMenu:Separator />
        <twig:DropdownMenu:Item>Log out</twig:DropdownMenu:Item>
    </twig:DropdownMenu:Content>
</twig:DropdownMenu>

Examples

Checkboxes

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 280px">
    <twig:DropdownMenu id="checkboxes">
        <twig:DropdownMenu:Trigger>
            <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
        </twig:DropdownMenu:Trigger>
        <twig:DropdownMenu:Content class="w-56">
            <twig:DropdownMenu:Label>Appearance</twig:DropdownMenu:Label>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:CheckboxItem checked>Status Bar</twig:DropdownMenu:CheckboxItem>
            <twig:DropdownMenu:CheckboxItem checked>Activity Bar</twig:DropdownMenu:CheckboxItem>
            <twig:DropdownMenu:CheckboxItem>Panel</twig:DropdownMenu:CheckboxItem>
            <twig:DropdownMenu:CheckboxItem disabled>Full Screen</twig:DropdownMenu:CheckboxItem>
        </twig:DropdownMenu:Content>
    </twig:DropdownMenu>
</div>

Radio Group

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 240px">
    <twig:DropdownMenu id="radio">
        <twig:DropdownMenu:Trigger>
            <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
        </twig:DropdownMenu:Trigger>
        <twig:DropdownMenu:Content class="w-56">
            <twig:DropdownMenu:Label>Panel Position</twig:DropdownMenu:Label>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:RadioGroup value="bottom">
                <twig:DropdownMenu:RadioItem value="top">Top</twig:DropdownMenu:RadioItem>
                <twig:DropdownMenu:RadioItem value="bottom" checked>Bottom</twig:DropdownMenu:RadioItem>
                <twig:DropdownMenu:RadioItem value="right">Right</twig:DropdownMenu:RadioItem>
            </twig:DropdownMenu:RadioGroup>
        </twig:DropdownMenu:Content>
    </twig:DropdownMenu>
</div>

Submenus

A DropdownMenu:Item can open a nested DropdownMenu:SubContent on hover or focus with DropdownMenu:Sub and DropdownMenu:SubTrigger.

100%
Loading...
<div class="flex w-[400px] items-start pt-6 ps-6" style="min-height: 340px">
    <twig:DropdownMenu id="submenu">
        <twig:DropdownMenu:Trigger>
            <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
        </twig:DropdownMenu:Trigger>
        <twig:DropdownMenu:Content class="w-48">
            <twig:DropdownMenu:Item>New Tab</twig:DropdownMenu:Item>
            <twig:DropdownMenu:Item>New Window</twig:DropdownMenu:Item>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Sub>
                <twig:DropdownMenu:SubTrigger>Share</twig:DropdownMenu:SubTrigger>
                <twig:DropdownMenu:SubContent>
                    <twig:DropdownMenu:Item>Copy link</twig:DropdownMenu:Item>
                    <twig:DropdownMenu:Item>Email</twig:DropdownMenu:Item>
                    <twig:DropdownMenu:Sub>
                        <twig:DropdownMenu:SubTrigger>More options</twig:DropdownMenu:SubTrigger>
                        <twig:DropdownMenu:SubContent>
                            <twig:DropdownMenu:Item>Messages</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item>Notes</twig:DropdownMenu:Item>
                        </twig:DropdownMenu:SubContent>
                    </twig:DropdownMenu:Sub>
                </twig:DropdownMenu:SubContent>
            </twig:DropdownMenu:Sub>
            <twig:DropdownMenu:Separator />
            <twig:DropdownMenu:Item>Print</twig:DropdownMenu:Item>
        </twig:DropdownMenu:Content>
    </twig:DropdownMenu>
</div>

Alignment

Use the align prop on DropdownMenu to align the menu to the start, center or end of the trigger.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 260px">
    <div class="flex flex-wrap justify-center gap-8">
        <twig:DropdownMenu id="align_start" align="start">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Start</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-40">
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:file" class="size-4" />New File</twig:DropdownMenu:Item>
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:folder" class="size-4" />New Folder</twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
        <twig:DropdownMenu id="align_center" align="center">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Center</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-40">
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:file" class="size-4" />New File</twig:DropdownMenu:Item>
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:folder" class="size-4" />New Folder</twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
        <twig:DropdownMenu id="align_end" align="end">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>End</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-40">
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:file" class="size-4" />New File</twig:DropdownMenu:Item>
                <twig:DropdownMenu:Item><twig:ux:icon name="lucide:folder" class="size-4" />New Folder</twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
    </div>
</div>

With Dialog

An item can open a Dialog: close the menu and open the dialog from the same click.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 360px">
    <twig:Dialog id="dropdown_dialog">
        <twig:DropdownMenu id="dialog">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Open</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-56">
                <twig:DropdownMenu:Label>My Account</twig:DropdownMenu:Label>
                <twig:DropdownMenu:Separator />
                <twig:DropdownMenu:Item>
                    <twig:ux:icon name="lucide:user" class="size-4" />
                    Profile
                </twig:DropdownMenu:Item>
                <twig:DropdownMenu:Separator />
                <twig:DropdownMenu:Item data-action="click->dropdown-menu#close click->dialog#open">
                    <twig:ux:icon name="lucide:user-plus" class="size-4" />
                    Invite users
                </twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
        <twig:Dialog:Content class="sm:max-w-[425px]">
            <twig:Dialog:Header>
                <twig:Dialog:Title>Invite team members</twig:Dialog:Title>
                <twig:Dialog:Description>Invite your team members to collaborate.</twig:Dialog:Description>
            </twig:Dialog:Header>
            <div class="grid gap-4">
                <div class="grid gap-3">
                    <twig:Label for="invite-email">Email address</twig:Label>
                    <twig:Input id="invite-email" type="email" placeholder="user@example.com" />
                </div>
            </div>
            <twig:Dialog:Footer>
                <twig:Dialog:Close>
                    <twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button>
                </twig:Dialog:Close>
                <twig:Button type="submit">Send invite</twig:Button>
            </twig:Dialog:Footer>
        </twig:Dialog:Content>
    </twig:Dialog>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="flex flex-col items-center gap-6 py-6" style="min-height: 420px">
    <div dir="rtl">
        <twig:DropdownMenu id="rtl_ar">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>افتح القائمة</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-48">
                <twig:DropdownMenu:Item>الملف الشخصي</twig:DropdownMenu:Item>
                <twig:DropdownMenu:Sub>
                    <twig:DropdownMenu:SubTrigger>مشاركة</twig:DropdownMenu:SubTrigger>
                    <twig:DropdownMenu:SubContent>
                        <twig:DropdownMenu:Item>بريد إلكتروني</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>رسالة</twig:DropdownMenu:Item>
                    </twig:DropdownMenu:SubContent>
                </twig:DropdownMenu:Sub>
                <twig:DropdownMenu:Separator />
                <twig:DropdownMenu:Item>تسجيل الخروج</twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
    </div>
    <div dir="rtl">
        <twig:DropdownMenu id="rtl_he">
            <twig:DropdownMenu:Trigger>
                <twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>פתח תפריט</twig:Button>
            </twig:DropdownMenu:Trigger>
            <twig:DropdownMenu:Content class="w-48">
                <twig:DropdownMenu:Item>פרופיל</twig:DropdownMenu:Item>
                <twig:DropdownMenu:Sub>
                    <twig:DropdownMenu:SubTrigger>שיתוף</twig:DropdownMenu:SubTrigger>
                    <twig:DropdownMenu:SubContent>
                        <twig:DropdownMenu:Item>אימייל</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>הודעה</twig:DropdownMenu:Item>
                    </twig:DropdownMenu:SubContent>
                </twig:DropdownMenu:Sub>
                <twig:DropdownMenu:Separator />
                <twig:DropdownMenu:Item>התנתקות</twig:DropdownMenu:Item>
            </twig:DropdownMenu:Content>
        </twig:DropdownMenu>
    </div>
</div>

API Reference

<twig:DropdownMenu>

Prop Type Default
id 
string -
open 
boolean false
side 
'top'|'right'|'bottom'|'left' 'bottom'
align 
'start'|'center'|'end' 'start'
Block Description
content A DropdownMenu:Trigger and a DropdownMenu:Content.

<twig:DropdownMenu:CheckboxItem>

Prop Type Default
checked 
boolean false
disabled 
boolean false
closeOnSelect 
boolean false
Block Description
content The label text.

<twig:DropdownMenu:Content>

Block Description
content The menu items, e.g. DropdownMenu:Item, DropdownMenu:Separator.

<twig:DropdownMenu:Group>

Block Description
content One or more DropdownMenu:Item components.

<twig:DropdownMenu:Item>

Prop Type Default
as 
'button'|'a'|'div' 'button'
disabled 
boolean false
inset 
boolean false
closeOnSelect 
boolean true
Block Description
content The item label.

<twig:DropdownMenu:Label>

Prop Type Default
inset 
boolean false
Block Description
content The label text.

<twig:DropdownMenu:RadioGroup>

Prop Type Default
value 
string|null null
Block Description
content One or more DropdownMenu:RadioItem components.

<twig:DropdownMenu:RadioItem>

Prop Type Default
value 
string -
checked 
boolean false
disabled 
boolean false
closeOnSelect 
boolean false
Block Description
content The label text.

<twig:DropdownMenu:Shortcut>

Block Description
content The shortcut label, e.g. "⌘K".

<twig:DropdownMenu:Sub>

Prop Type Default
openDelay 
number 0
closeDelay 
number 0
Block Description
content A DropdownMenu:SubTrigger and a DropdownMenu:SubContent.

<twig:DropdownMenu:SubContent>

Block Description
content The sub-menu items, e.g. DropdownMenu:Item.

<twig:DropdownMenu:SubTrigger>

Prop Type Default
inset 
boolean false
Block Description
content The sub-menu trigger label.

<twig:DropdownMenu:Trigger>

Block Description
content The trigger label (e.g., a Button) that opens the menu on click.