Dropdown
Anchored menu primitive with full keyboard navigation and ARIA menu semantics.
Requires Alpine.js
Examples
Default preview
{{ _item_classes = 'flex w-full items-center rounded-sm px-3 py-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-secondary/70 focus-visible:bg-secondary/70 aria-selected:bg-secondary/80 aria-selected:text-foreground' }}
<div class="flex flex-wrap items-start gap-4">
{{ partial:components/primitives/dropdown }}
{{ slot:trigger }}Project actions{{ /slot:trigger }}
{{ slot:menu }}
<button type="button" role="menuitem" class="{{ _item_classes }}">Edit project</button>
<button type="button" role="menuitem" class="{{ _item_classes }}" aria-selected="true">View roadmap</button>
<button type="button" role="menuitem" class="{{ _item_classes }}">Duplicate</button>
<div class="bg-border my-1 h-px" role="separator" aria-hidden="true"></div>
<button type="button" role="menuitem" class="{{ _item_classes }} text-destructive">Delete project</button>
{{ /slot:menu }}
{{ /partial:components/primitives/dropdown }}
{{ partial:components/primitives/dropdown }}
{{ slot:trigger }}Account{{ /slot:trigger }}
{{ slot:menu }}
<a href="#" role="menuitem" class="{{ _item_classes }}">Profile</a>
<a href="#" role="menuitem" class="{{ _item_classes }}">Settings</a>
<a href="#" role="menuitem" class="{{ _item_classes }}">Sign out</a>
{{ /slot:menu }}
{{ /partial:components/primitives/dropdown }}
</div>
{{ _item_classes = 'flex w-full items-center rounded-sm px-3 py-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-secondary/70 focus-visible:bg-secondary/70 aria-selected:bg-secondary/80 aria-selected:text-foreground' }}
<div class="flex flex-wrap items-start gap-4">
{{ partial:components/primitives/dropdown }}
{{ slot:trigger }}Project actions{{ /slot:trigger }}
{{ slot:menu }}
<button type="button" role="menuitem" class="{{ _item_classes }}">Edit project</button>
<button type="button" role="menuitem" class="{{ _item_classes }}" aria-selected="true">View roadmap</button>
<button type="button" role="menuitem" class="{{ _item_classes }}">Duplicate</button>
<div class="bg-border my-1 h-px" role="separator" aria-hidden="true"></div>
<button type="button" role="menuitem" class="{{ _item_classes }} text-destructive">Delete project</button>
{{ /slot:menu }}
{{ /partial:components/primitives/dropdown }}
{{ partial:components/primitives/dropdown }}
{{ slot:trigger }}Account{{ /slot:trigger }}
{{ slot:menu }}
<a href="#" role="menuitem" class="{{ _item_classes }}">Profile</a>
<a href="#" role="menuitem" class="{{ _item_classes }}">Settings</a>
<a href="#" role="menuitem" class="{{ _item_classes }}">Sign out</a>
{{ /slot:menu }}
{{ /partial:components/primitives/dropdown }}
</div>
Props
| Name | Type | Default | Description |
|---|---|---|---|
class
|
string
|
|
Additional classes merged via tw_merge (root element only) |
Source
{{#
@name Dropdown
@desc Anchored menu primitive with full keyboard navigation and ARIA menu semantics.
@param class string - Additional classes merged via tw_merge (root element only)
#}}
{{ _root_classes = 'relative inline-flex {class}'
| tw_merge }}
<div
class="{{ _root_classes }}"
x-data="{
isOpen: false,
menuItems: [],
refreshMenuItems() {
this.menuItems = Array.from(this.$refs.menu?.querySelectorAll('[role=menuitem]') ?? []).filter((item) => {
return !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true';
});
this.menuItems.forEach((item) => {
if (!item.hasAttribute('tabindex')) {
item.setAttribute('tabindex', '-1');
}
});
},
focusItem(index) {
if (!this.menuItems.length) return;
const boundedIndex = (index + this.menuItems.length) % this.menuItems.length;
this.menuItems[boundedIndex].focus();
},
focusFirst() {
this.focusItem(0);
},
focusLast() {
this.focusItem(this.menuItems.length - 1);
},
focusNext() {
const currentIndex = this.menuItems.indexOf(document.activeElement);
this.focusItem(currentIndex + 1);
},
focusPrev() {
const currentIndex = this.menuItems.indexOf(document.activeElement);
this.focusItem(currentIndex - 1);
},
openMenu(focus = 'first') {
if (this.isOpen) return;
this.isOpen = true;
this.$nextTick(() => {
this.refreshMenuItems();
if (focus === 'last') {
this.focusLast();
return;
}
this.focusFirst();
});
},
closeMenu(focusTrigger = false) {
if (!this.isOpen) return;
this.isOpen = false;
if (focusTrigger) {
this.$nextTick(() => this.$refs.trigger?.focus());
}
},
toggleMenu() {
if (this.isOpen) {
this.closeMenu();
return;
}
this.openMenu();
}
}"
x-id="['dropdown-trigger', 'dropdown-menu']"
@keydown.escape.window="if (isOpen) closeMenu(true)"
>
<button
x-ref="trigger"
type="button"
class="group rounded-default border-border bg-background text-foreground hover:bg-secondary/50 focus-visible:ring-ring focus-visible:ring-offset-background aria-expanded:bg-secondary/70 inline-flex items-center gap-2 border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
:id="$id('dropdown-trigger')"
:aria-expanded="isOpen"
:aria-controls="$id('dropdown-menu')"
aria-haspopup="menu"
@click="toggleMenu()"
@keydown.down.prevent.stop="openMenu('first')"
@keydown.up.prevent.stop="openMenu('last')"
>
<span>
{{ if slot:trigger }}
{{ slot:trigger }}
{{ else }}
Open menu
{{ /if }}
</span>
<span
class="text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180"
aria-hidden="true"
>
{{ svg src="icons/chevron-down" class="size-5" }}
</span>
</button>
<div
x-ref="menu"
x-show="isOpen"
x-transition.origin.top.left
x-anchor.bottom-start.offset.4="$refs.trigger"
class="rounded-default border-border bg-background/95 z-50 min-w-52 border p-1 shadow-lg backdrop-blur-lg"
:id="$id('dropdown-menu')"
role="menu"
:aria-labelledby="$id('dropdown-trigger')"
@click.outside="closeMenu()"
@keydown.down.prevent="focusNext()"
@keydown.up.prevent="focusPrev()"
@keydown.home.prevent="focusFirst()"
@keydown.end.prevent="focusLast()"
@keydown.escape.prevent.stop="closeMenu(true)"
@keydown.tab="closeMenu()"
style="display: none"
>
{{ if slot:menu }}
{{ slot:menu }}
{{ else }}
{{ slot }}
{{ /if }}
</div>
</div>
{{#
@name Dropdown
@desc Anchored menu primitive with full keyboard navigation and ARIA menu semantics.
@param class string - Additional classes merged via tw_merge (root element only)
#}}
{{ _root_classes = 'relative inline-flex {class}'
| tw_merge }}
<div
class="{{ _root_classes }}"
x-data="{
isOpen: false,
menuItems: [],
refreshMenuItems() {
this.menuItems = Array.from(this.$refs.menu?.querySelectorAll('[role=menuitem]') ?? []).filter((item) => {
return !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true';
});
this.menuItems.forEach((item) => {
if (!item.hasAttribute('tabindex')) {
item.setAttribute('tabindex', '-1');
}
});
},
focusItem(index) {
if (!this.menuItems.length) return;
const boundedIndex = (index + this.menuItems.length) % this.menuItems.length;
this.menuItems[boundedIndex].focus();
},
focusFirst() {
this.focusItem(0);
},
focusLast() {
this.focusItem(this.menuItems.length - 1);
},
focusNext() {
const currentIndex = this.menuItems.indexOf(document.activeElement);
this.focusItem(currentIndex + 1);
},
focusPrev() {
const currentIndex = this.menuItems.indexOf(document.activeElement);
this.focusItem(currentIndex - 1);
},
openMenu(focus = 'first') {
if (this.isOpen) return;
this.isOpen = true;
this.$nextTick(() => {
this.refreshMenuItems();
if (focus === 'last') {
this.focusLast();
return;
}
this.focusFirst();
});
},
closeMenu(focusTrigger = false) {
if (!this.isOpen) return;
this.isOpen = false;
if (focusTrigger) {
this.$nextTick(() => this.$refs.trigger?.focus());
}
},
toggleMenu() {
if (this.isOpen) {
this.closeMenu();
return;
}
this.openMenu();
}
}"
x-id="['dropdown-trigger', 'dropdown-menu']"
@keydown.escape.window="if (isOpen) closeMenu(true)"
>
<button
x-ref="trigger"
type="button"
class="group rounded-default border-border bg-background text-foreground hover:bg-secondary/50 focus-visible:ring-ring focus-visible:ring-offset-background aria-expanded:bg-secondary/70 inline-flex items-center gap-2 border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
:id="$id('dropdown-trigger')"
:aria-expanded="isOpen"
:aria-controls="$id('dropdown-menu')"
aria-haspopup="menu"
@click="toggleMenu()"
@keydown.down.prevent.stop="openMenu('first')"
@keydown.up.prevent.stop="openMenu('last')"
>
<span>
{{ if slot:trigger }}
{{ slot:trigger }}
{{ else }}
Open menu
{{ /if }}
</span>
<span
class="text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180"
aria-hidden="true"
>
{{ svg src="icons/chevron-down" class="size-5" }}
</span>
</button>
<div
x-ref="menu"
x-show="isOpen"
x-transition.origin.top.left
x-anchor.bottom-start.offset.4="$refs.trigger"
class="rounded-default border-border bg-background/95 z-50 min-w-52 border p-1 shadow-lg backdrop-blur-lg"
:id="$id('dropdown-menu')"
role="menu"
:aria-labelledby="$id('dropdown-trigger')"
@click.outside="closeMenu()"
@keydown.down.prevent="focusNext()"
@keydown.up.prevent="focusPrev()"
@keydown.home.prevent="focusFirst()"
@keydown.end.prevent="focusLast()"
@keydown.escape.prevent.stop="closeMenu(true)"
@keydown.tab="closeMenu()"
style="display: none"
>
{{ if slot:menu }}
{{ slot:menu }}
{{ else }}
{{ slot }}
{{ /if }}
</div>
</div>
Dependencies
Packages
marcorieser/tailwind-merge-statamic
composer require marcorieser/tailwind-merge-statamic
composer require marcorieser/tailwind-merge-statamic
@alpinejs/anchor
npm install @alpinejs/anchor
npm install @alpinejs/anchor
Internal dependencies
- Uses: role="menu" + role="menuitem" semantics and keyboard navigation (Arrow keys, Home/End, Escape)
- Uses: x-id + $id() for unique trigger/menu IDs
- Slots: trigger, menu (default slot also supported)