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

Note

Available since UX Toolkit 3.5.

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
{%- props
    ## string Unique identifier used to generate the internal DropdownMenu IDs.
    id,
    ## boolean Whether the menu is open on initial render.
    open = false,
    ## 'top'|'right'|'bottom'|'left' Which side of the trigger the menu appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the menu along the cross axis.
    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',
    }) }}
>
    {##- A `DropdownMenu:Trigger` and a `DropdownMenu:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/CheckboxItem.html.twig
{%- props
    ## boolean Whether the checkbox is initially checked.
    checked = false,
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether selecting the item closes the menu.
    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>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</button>
templates/components/DropdownMenu/Content.html.twig
{%- 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,
    }) }}
>
    {##- The menu items, e.g. `DropdownMenu:Item`, `DropdownMenu:Separator`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Group.html.twig
<div role="group" data-slot="dropdown-menu-group" {{ attributes }}>
    {##- One or more `DropdownMenu:Item` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Item.html.twig
{%- props
    ## 'button'|'a'|'div' The HTML tag to render.
    as = 'button',
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether the item is inset with extra start padding.
    inset = false,
    ## boolean Whether selecting the item closes the menu.
    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',
    }) }}
>
    {##- The item label. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/DropdownMenu/Label.html.twig
{%- props
    ## boolean Whether the label is inset with extra start padding.
    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,
    }) }}
>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioGroup.html.twig
{%- props
    ## string|null The currently selected value.
    value = null
-%}
<div
    role="group"
    data-slot="dropdown-menu-radio-group"
    data-value="{{ value }}"
    {{ attributes }}
>
    {##- One or more `DropdownMenu:RadioItem` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioItem.html.twig
{%- props
    ## string The value submitted when this radio is selected.
    value,
    ## boolean Whether this radio is initially checked.
    checked = false,
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether selecting the item closes the menu.
    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>
    {##- The label text. -#}
    {%- 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
<span
    data-slot="dropdown-menu-shortcut"
    {{ attributes.defaults({
        class: 'ms-auto text-xs tracking-widest text-muted-foreground'|tailwind_classes,
    }) }}
>
    {##- The shortcut label, e.g. "⌘K". -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/DropdownMenu/Sub.html.twig
{%- props
    ## number Delay in milliseconds before showing the sub-menu.
    openDelay = 0,
    ## number Delay in milliseconds before hiding the sub-menu.
    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',
    }) }}
>
    {##- A `DropdownMenu:SubTrigger` and a `DropdownMenu:SubContent`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubContent.html.twig
<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,
    }) }}
>
    {##- The sub-menu items, e.g. `DropdownMenu:Item`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubTrigger.html.twig
{%- props
    ## boolean Whether the trigger is inset with extra start padding.
    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',
    }) }}
>
    {##- The sub-menu trigger label. -#}
    {%- 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
{%- 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'),
} -%}
{##- The trigger label (e.g., a `Button`) that opens the menu on click. -#}
{%- 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>

Accessibility

  • DropdownMenu:Content renders role="menu" and its entries role="menuitem", role="menuitemcheckbox" or role="menuitemradio", with aria-checked kept in sync by the controller.
  • DropdownMenu:Trigger exposes aria-haspopup="menu", an aria-expanded reflecting the open state and an aria-controls pointing at the menu.
  • ArrowDown and ArrowUp move between items and wrap around, Home and End jump to the first and last, and Escape closes the menu and returns focus to the trigger. Disabled items are skipped.
  • DropdownMenu:Separator renders role="separator", and DropdownMenu:Group renders role="group". Label a group with aria-labelledby pointing at its DropdownMenu:Label.
  • A disabled item renders aria-disabled="true" rather than being removed, so its presence stays discoverable.
  • DropdownMenu:Shortcut shows the key combination visually. It is announced as part of the item text, so keep it short.

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.