Kern Component Library GitHub GitHub

Dropdown

Anchored menu primitive with full keyboard navigation and ARIA role="menu" semantics. Requires @alpinejs/anchor. Menu items must carry role="menuitem" for keyboard navigation to work.
Requires Alpine.js

Default

1{{ partial:components/primitives/dropdown label="Options" }}
2 {{ slot:menu }}
3 <button
4 class="hover:bg-secondary focus-visible:ring-ring w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
5 role="menuitem"
6 >
7 Edit
8 </button>
9 <button
10 class="hover:bg-secondary focus-visible:ring-ring w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
11 role="menuitem"
12 >
13 Duplicate
14 </button>
15 <button
16 class="hover:bg-secondary focus-visible:ring-ring w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
17 role="menuitem"
18 >
19 Archive
20 </button>
21
22 <hr class="border-border my-1" />
23 <button
24 class="hover:bg-destructive/10 focus-visible:ring-ring text-destructive w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
25 role="menuitem"
26 >
27 Delete
28 </button>
29 {{ /slot:menu }}
30{{ /partial:components/primitives/dropdown }}

Custom Trigger

1{{ partial:components/primitives/dropdown }}
2 {{ slot:trigger }}
3 {{ svg src="icons/star" class="size-4 shrink-0" aria-hidden="true" }}
4 Options
5 {{ /slot:trigger }}
6 {{ slot:menu }}
7 <button
8 class="hover:bg-secondary focus-visible:ring-ring w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
9 role="menuitem"
10 >
11 Edit
12 </button>
13 <button
14 class="hover:bg-secondary focus-visible:ring-ring w-full rounded px-3 py-1.5 text-left text-sm focus-visible:ring-2 focus-visible:outline-none"
15 role="menuitem"
16 >
17 Delete
18 </button>
19 {{ /slot:menu }}
20{{ /partial:components/primitives/dropdown }}

Props

Name Type Default Description
label string Trigger button label text (falls back to slot:trigger, then "Open menu")
class string Additional classes merged via tw_merge (root element only)

Slots

Name Fallback / Default Description
default
menu default slot Menu content; items should have role="menuitem" (falls back to default slot)
trigger label Trigger button content as HTML (overrides label)

Source

1{{#
2 @name Dropdown
3 @desc Anchored menu primitive with full keyboard navigation and ARIA role="menu" semantics. Requires @alpinejs/anchor. Menu items must carry role="menuitem" for keyboard navigation to work.
4 @param label string - Trigger button label text (falls back to slot:trigger, then "Open menu")
5 @param class string - Additional classes merged via tw_merge (root element only)
6 @slot trigger - Trigger button content as HTML (overrides label)
7 @slot menu - Menu content; items should have role="menuitem" (falls back to default slot)
8#}}
9{{ _class = 'relative inline-flex {class}'
10 | tw_merge }}
11<div
12 class="{{ _class }}"
13 x-data="{
14 isOpen: false,
15 menuItems: [],
16 refreshMenuItems() {
17 this.menuItems = Array.from(this.$refs.menu?.querySelectorAll('[role=menuitem]') ?? []).filter((item) => {
18 return !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true';
19 });
20
21 this.menuItems.forEach((item) => {
22 if (!item.hasAttribute('tabindex')) {
23 item.setAttribute('tabindex', '-1');
24 }
25 });
26 },
27 focusItem(index) {
28 if (!this.menuItems.length) return;
29 const boundedIndex = (index + this.menuItems.length) % this.menuItems.length;
30 this.menuItems[boundedIndex].focus();
31 },
32 focusFirst() { this.focusItem(0); },
33 focusLast() { this.focusItem(this.menuItems.length - 1); },
34 focusNext() {
35 const currentIndex = this.menuItems.indexOf(document.activeElement);
36 this.focusItem(currentIndex + 1);
37 },
38 focusPrev() {
39 const currentIndex = this.menuItems.indexOf(document.activeElement);
40 this.focusItem(currentIndex - 1);
41 },
42 openMenu(focus = 'first') {
43 if (this.isOpen) return;
44 this.isOpen = true;
45 this.$nextTick(() => {
46 this.refreshMenuItems();
47 focus === 'last' ? this.focusLast() : this.focusFirst();
48 });
49 },
50 closeMenu(focusTrigger = false) {
51 if (!this.isOpen) return;
52 this.isOpen = false;
53 if (focusTrigger) this.$nextTick(() => this.$refs.trigger?.focus());
54 },
55 toggleMenu() {
56 this.isOpen ? this.closeMenu() : this.openMenu();
57 }
58 }"
59 x-id="['dropdown-trigger', 'dropdown-menu']"
60 @keydown.escape.window="if (isOpen) closeMenu(true)"
61>
62 {{ partial:components/primitives/button intent="ghost" class="group h-auto border border-border bg-background px-3 py-2 text-sm aria-expanded:bg-secondary/70 hover:bg-secondary/50" }}
63 {{ slot:attrs }}
64 x-ref="trigger" :id="$id('dropdown-trigger')" :aria-expanded="isOpen" :aria-controls="$id('dropdown-menu')"
65 aria-haspopup="menu" @click="toggleMenu()" @keydown.down.prevent.stop="openMenu('first')"
66 @keydown.up.prevent.stop="openMenu('last')"
67 {{ /slot:attrs }}
68 {{ if label }}
69 {{ label }}
70 {{ elseif slot:trigger }}
71 {{ slot:trigger }}
72 {{ else }}
73 Open menu
74 {{ /if }}
75 {{ slot:after }}
76 <span
77 class="text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180"
78 aria-hidden="true"
79 >
80 {{ svg src="icons/chevron-down" class="size-5" }}
81 </span>
82 {{ /slot:after }}
83 {{ /partial:components/primitives/button }}
84 <div
85 x-ref="menu"
86 x-show="isOpen"
87 x-transition.origin.top.left
88 x-anchor.bottom-start.offset.4="$refs.trigger"
89 class="rounded-default border-border bg-background/95 z-50 min-w-52 border p-1 shadow-lg backdrop-blur-lg"
90 :id="$id('dropdown-menu')"
91 role="menu"
92 :aria-labelledby="$id('dropdown-trigger')"
93 @click.outside="closeMenu()"
94 @keydown.down.prevent="focusNext()"
95 @keydown.up.prevent="focusPrev()"
96 @keydown.home.prevent="focusFirst()"
97 @keydown.end.prevent="focusLast()"
98 @keydown.escape.prevent.stop="closeMenu(true)"
99 @keydown.tab="closeMenu()"
100 style="display: none"
101 >
102 {{ if slot:menu }}
103 {{ slot:menu }}
104 {{ else }}
105 {{ slot }}
106 {{ /if }}
107 </div>
108</div>

Dependencies

Packages

1composer require marcorieser/tailwind-merge-statamic
2npm install alpinejs @alpinejs/anchor

Internal dependency graph

Current

Dropdown

/primitives/dropdown

Transitive

None