Kern 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-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] focus-visible:outline-none"
5 role="menuitem"
6 >
7 Edit
8 </button>
9 <button
10 class="hover:bg-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] focus-visible:outline-none"
11 role="menuitem"
12 >
13 Duplicate
14 </button>
15 <button
16 class="hover:bg-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] focus-visible:outline-none"
17 role="menuitem"
18 >
19 Archive
20 </button>
21
22 <hr class="border-foreground my-1" />
23 <button
24 class="text-destructive hover:bg-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] 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-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] focus-visible:outline-none"
9 role="menuitem"
10 >
11 Edit
12 </button>
13 <button
14 class="hover:bg-focus hover:text-focus-text focus-visible:bg-focus focus-visible:text-focus-text w-full px-3 py-1.5 text-left text-sm hover:font-bold focus-visible:font-bold focus-visible:[box-shadow:0_4px_var(--color-focus-text)] 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 {{# Trigger — ghost-style button, border focus per DESIGN.md #}}
63 <button
64 type="button"
65 class="group focus-visible:border-focus-text focus-visible:outline-focus active:border-focus active:bg-focus active:text-focus-text inline-flex cursor-pointer items-center justify-center gap-2 border-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors hover:border-current focus-visible:outline focus-visible:outline-4"
66 x-ref="trigger"
67 :id="$id('dropdown-trigger')"
68 :aria-expanded="isOpen"
69 :aria-controls="$id('dropdown-menu')"
70 aria-haspopup="menu"
71 @click="toggleMenu()"
72 @keydown.down.prevent.stop="openMenu('first')"
73 @keydown.up.prevent.stop="openMenu('last')"
74 >
75 {{ if label }}
76 {{ label }}
77 {{ elseif slot:trigger }}
78 {{ slot:trigger }}
79 {{ else }}
80 Open menu
81 {{ /if }}
82 <span
83 class="text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180"
84 aria-hidden="true"
85 >
86 {{ svg src="icons/chevron-down" class="size-5" }}
87 </span>
88 </button>
89 <div
90 x-ref="menu"
91 x-show="isOpen"
92 x-transition.origin.top.left
93 x-anchor.bottom-start.offset.4="$refs.trigger"
94 class="border-foreground bg-background z-50 min-w-52 border"
95 :id="$id('dropdown-menu')"
96 role="menu"
97 :aria-labelledby="$id('dropdown-trigger')"
98 @click.outside="closeMenu()"
99 @keydown.down.prevent="focusNext()"
100 @keydown.up.prevent="focusPrev()"
101 @keydown.home.prevent="focusFirst()"
102 @keydown.end.prevent="focusLast()"
103 @keydown.escape.prevent.stop="closeMenu(true)"
104 @keydown.tab="closeMenu()"
105 style="display: none"
106 >
107 {{ if slot:menu }}
108 {{ slot:menu }}
109 {{ else }}
110 {{ slot }}
111 {{ /if }}
112 </div>
113</div>

Dependencies

Packages

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