- Installation
- Usage
- Examples
- Checkboxes
- Radio Group
- Submenus
- Alignment
- With Dialog
- RTL
- API Reference
-
DropdownMenu -
DropdownMenu:CheckboxItem -
DropdownMenu:Content -
DropdownMenu:Group -
DropdownMenu:Item -
DropdownMenu:Label -
DropdownMenu:RadioGroup -
DropdownMenu:RadioItem -
DropdownMenu:Shortcut -
DropdownMenu:Sub -
DropdownMenu:SubContent -
DropdownMenu:SubTrigger -
DropdownMenu:Trigger
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 Unique identifier used to generate the internal DropdownMenu IDs.
|
string |
- |
open Whether the menu is open on initial render.
|
boolean |
false |
side Which side of the trigger the menu appears on.
|
'top'|'right'|'bottom'|'left' |
'bottom' |
align Alignment of the menu along the cross axis.
|
'start'|'center'|'end' |
'start' |
| Block | Description |
|---|---|
content |
A DropdownMenu:Trigger and a DropdownMenu:Content. |
<twig:DropdownMenu:CheckboxItem>
| Prop | Type | Default |
|---|---|---|
checked Whether the checkbox is initially checked.
|
boolean |
false |
disabled Whether the item is disabled.
|
boolean |
false |
closeOnSelect Whether selecting the item closes the menu.
|
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 The HTML tag to render.
|
'button'|'a'|'div' |
'button' |
disabled Whether the item is disabled.
|
boolean |
false |
inset Whether the item is inset with extra start padding.
|
boolean |
false |
closeOnSelect Whether selecting the item closes the menu.
|
boolean |
true |
| Block | Description |
|---|---|
content |
The item label. |
<twig:DropdownMenu:Label>
| Prop | Type | Default |
|---|---|---|
inset Whether the label is inset with extra start padding.
|
boolean |
false |
| Block | Description |
|---|---|
content |
The label text. |
<twig:DropdownMenu:RadioGroup>
| Prop | Type | Default |
|---|---|---|
value The currently selected value.
|
string|null |
null |
| Block | Description |
|---|---|
content |
One or more DropdownMenu:RadioItem components. |
<twig:DropdownMenu:RadioItem>
| Prop | Type | Default |
|---|---|---|
value The value submitted when this radio is selected.
|
string |
- |
checked Whether this radio is initially checked.
|
boolean |
false |
disabled Whether the item is disabled.
|
boolean |
false |
closeOnSelect Whether selecting the item closes the menu.
|
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 Delay in milliseconds before showing the sub-menu.
|
number |
0 |
closeDelay Delay in milliseconds before hiding the sub-menu.
|
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 Whether the trigger is inset with extra start padding.
|
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. |