Dropdown
The <x-wirekit:: component creates accessible dropdown menus with keyboard navigation, Floating UI positioning, and automatic flip/shift behavior.
Usage
Composition forms
<x-wirekit:: supports two equivalent composition forms. Use whichever reads better for the page.
Quick form (named :trigger slot) — the parent auto-wraps the trigger element and the menu items in the canonical sub-component shells. Less boilerplate when the trigger doesn't need extra props on its wrapper:
Name the menu with label. A screen reader announces an unnamed menu as a bare "menu", which says that one opened and not which. In the quick form label goes on the dropdown, which hands it to the panel it composes for you; in the explicit form it goes on <x-wirekit::, because that panel is yours.
Explicit form — nest <x-wirekit:: and <x-wirekit:: directly when you need finer control over their props (e.g. panel width="trigger", a per-sub-component scope=). Shown in the Usage example above.
The quick form composes the trigger and the panel for you, so an explicit
<x-wirekit:: beside a <x-slot:trigger> is repeating work the component
already does. Nothing renders twice — the component detects the panel and passes it through
untouched — but it is a shell with no job, and the component says so in your development
log. Removing it renders the same markup.
Trigger Indicator
A trigger that opens a menu tells a screen reader so through aria-expanded, and nothing tells a sighted reader. Put <x-wirekit:: inside the trigger's button, after its label, and the button shows a chevron that turns while the menu is open:
The chevron is hidden from assistive technology, because the button already announces the menu, and it takes the button's text color. It turns on the button's own aria-expanded, so it follows the state a screen reader hears. Place it inside the button rather than beside it: outside the button there is no expanded state to follow.
Width & Layout
The dropdown panel auto-sizes to its widest item. To set a fixed width, use Tailwind on the panel:
<x-wirekit::dropdown.panel class="w-56">
<x-wirekit::dropdown.item>…</x-wirekit::dropdown.item>
</x-wirekit::dropdown.panel>
Placement
Control where the panel appears relative to the trigger:
<x-wirekit::dropdown placement="bottom-end">
...
</x-wirekit::dropdown>
Available placements: top, top-start, top-end, bottom, bottom-start, bottom-end, left, left-start, left-end, right, right-start, right-end.
The panel automatically flips to the opposite side when there's not enough space and shifts to stay within the viewport.
Panel Width
<!-- Auto width (default) — uses min-width: 12rem -->
<x-wirekit::dropdown.panel>
<!-- Match trigger width -->
<x-wirekit::dropdown.panel width="trigger">
<!-- Custom width -->
<x-wirekit::dropdown.panel width="20rem">
Icons
Items can display icons using WireKit's icon alias system:
<x-wirekit::dropdown.item icon="edit" href="/edit">Edit</x-wirekit::dropdown.item>
<x-wirekit::dropdown.item icon="trash" :danger="true">Delete</x-wirekit::dropdown.item>
Icons require the Icon system to be configured with a preset and the blade-icons package installed.
Danger Items
Use the danger prop for destructive actions — items are tinted with the danger color:
Sign out & form actions
A menu item with no href renders a <button>. In a Livewire app, wire it straight to an action:
<x-wirekit::dropdown.item wire:click="logout" :danger="true">Sign out</x-wirekit::dropdown.item>
For a non-Livewire app (Breeze, Jetstream, Fortify), the logout route is a CSRF-protected POST. Give the item type="submit" and wrap it in a <form> — clicking it submits the form:
<x-wirekit::dropdown.panel>
<x-wirekit::dropdown.item href="/settings">Settings</x-wirekit::dropdown.item>
<x-wirekit::dropdown.separator />
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-wirekit::dropdown.item type="submit" :danger="true">Sign out</x-wirekit::dropdown.item>
</form>
</x-wirekit::dropdown.panel>
The item keeps its role="menuitem" and keyboard behavior; only the underlying button type changes from the default button to submit.
Disabled Items
Disabled items are visually muted, not focusable via keyboard navigation, and have aria-disabled="true":
Checkbox, Radio & Shortcut Items
Beyond plain items, the dropdown ships menu primitives that match the WAI-ARIA Menu pattern:
dropdown.— a self-togglingcheckbox-item menuitemcheckbox(Alpine owns the checked state, seeded from:checked). Add your own@clickto also sync Livewire.dropdown.— aradio-item menuitemradiowhose selection is coordinated by a shared Alpine variable named viamodel(set on an ancestorx-data).shortcutprop — a keyboard-shortcut hint pinned to the inline-end of any item. The hint is hidden from screen readers, so a key your application binds goes on the item asaria-keyshortcutsas well, such asaria-keyshortcuts="Control+N", which reaches itsmenuitem.
Optimistic UI
A checkbox item's checked state usually lives on the server even though the menu around it does not. Pass the Livewire method to call and the new state shows immediately, before the answer arrives:
<x-wirekit::dropdown.checkbox-item
:checked="$wrapLines"
optimistic="saveWrapLines"
>
Wrap lines
</x-wirekit::dropdown.checkbox-item>
Load wirekit-optimistic. alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></script>
Try it
The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.
The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.
A refusal puts the old state back. The previous value belongs to the server and is simply the other choice, so restoring it costs you nothing — unlike a field you typed into. The flip announces itself once as provisional; a confirmation is silent, and only the undo speaks a second time.
With optimistic set, the item is wrapped in an extra element, so a selector like .menu > [role="menuitemcheckbox"] or :first-child stops matching it: check such a selector before you set the prop. The wrapper uses display: contents, so the layout is unchanged — and it carries no role, which keeps the menu owning its items in the accessibility tree. Without the prop the item renders no wrapper at all.
optimistic and the self-toggling default are mutually exclusive by construction: the layer performs the flip, so a second local one would fight it. See Optimistic UI for the full contract.
Submenus
Nest a <x-wirekit:: inside a panel to add a flyout sub-menu. The parent item opens a child panel beside it (on hover, click, or ArrowRight); the child holds ordinary <x-wirekit::s. Selecting a leaf item closes the whole menu, just like a top-level item.
Pass a label for plain parent text, or a <x-slot:label> for rich content (icon + text). The parent carries aria-haspopup="menu" + aria-expanded; the child panel is its own role="menu". Submenus nest arbitrarily deep — put a <x-wirekit:: inside another submenu's panel.
Marking the current page
In a navigation menu the most common thing an entry can be is the page you are already on. Pass
active:
{{-- 1. `active` sets aria-current and marks the entry visually. --}}
{{-- 2. Compare against your own route — the component does not guess. --}}
<x-wirekit::dropdown.item href="/settings" :active="request()->is('settings')">
Settings
</x-wirekit::dropdown.item>
It is marked with weight and color, not a background, and that is a deliberate choice rather than a stylistic one. Hover and focus already use the subtle surface. A third state painted the same way would be indistinguishable from them at exactly the moment it matters — when the current entry is also the one under the pointer.
aria-current is set to page on an item with an href and to true on one without, because an
item that is not a link is an action rather than a destination. If you pass your own
aria-current, yours is kept.
When a menu opens it moves focus to its first item so keyboard users land inside it. That focus is
drawn with focus-visible, so it appears for someone navigating by keyboard and not for someone
who clicked — and it carries a ring rather than only a filled row, so it reads as a caret position
rather than as "you are here". Without an active item, a menu marks nothing at all, which is the
right answer when the reader is on a page the menu does not list.
Behavior
- Click outside closes the dropdown automatically
- Floating UI (bundled) handles positioning with flip and shift middleware
- Livewire SPA navigation (
wire:navigate) automatically closes open dropdowns - Transitions use scale + opacity for smooth open/close animation
Props
<x-wirekit::dropdown>
| Prop | Type | Default | Description |
|---|---|---|---|
placement |
string |
'bottom-start' |
Floating UI placement |
offset |
int |
8 |
Distance in px between trigger and panel |
name |
string|null |
null |
Pins the panel id. Give one when the dropdown sits inside a Livewire component and you want the id to survive a round trip verbatim |
block |
bool |
false |
Fills the row the dropdown sits in instead of shrinking to its trigger. Reach for it where the dropdown is the row — the account trigger at the foot of a sidebar column, whose highlight has to span the row like the navigation entries above it. A width set on the trigger cannot do this on its own: it resolves against a wrapper already sized to that same trigger |
label |
string|null |
null |
Accessible name for the menu in the quick form, handed to the panel the dropdown composes for you. In the explicit form, give it to <x-wirekit::dropdown.panel> instead: that panel is yours, and the dropdown cannot reach it. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.trigger>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabelFallback |
string |
'Open menu' |
Auto-injected aria-label for the inner button when it has no accessible name (icon-only triggers, responsive layouts that hide the visible label below sm). Skipped when the inner button already carries aria-label / aria-labelledby / visible-or-sr-only text. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.indicator>
| Prop | Type | Default | Description |
|---|---|---|---|
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.panel>
| Prop | Type | Default | Description |
|---|---|---|---|
width |
string |
'auto' |
Panel width: 'auto', 'trigger', or any CSS value |
label |
string|null |
null |
Accessible name for the menu. Without one a screen reader announces a bare "menu" — it says a menu opened and not which. Name it after what the trigger offers (label="Export options"), and leave it off rather than passing something generic: "Menu" is announced as "Menu, menu" |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.item>
| Prop | Type | Default | Description |
|---|---|---|---|
href |
string|null |
null |
URL (renders <a> instead of <button>) |
danger |
bool |
false |
Destructive/danger styling |
disabled |
bool |
false |
Disabled state |
icon |
string|null |
null |
WireKit icon alias (e.g. 'edit', 'trash') |
active |
bool |
false |
Marks the entry as the page the reader is on. Sets aria-current and a weight/color change — deliberately not a background, see Marking the current page |
shortcut |
string|null |
null |
Keyboard-shortcut hint rendered at the inline end (e.g. ⌘K). Display only — it does not bind the key. Hidden from screen readers: state a key your application binds with aria-keyshortcuts on the item, such as Control+N. |
role |
string |
'menuitem' |
The item's ARIA role. Opt-in rather than inferred, because an item cannot see whether its siblings form a set. A group of mutually exclusive options is menuitemradio + aria-checked under the APG menu pattern, and aria-current is not a substitute: it says which entry is the current one and carries no group membership, so a reader hears "System, current" instead of "System, radio button, checked, 1 of 3". Prefer <x-wirekit::dropdown.radio-item>, which wires both. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.checkbox-item>
| Prop | Type | Default | Description |
|---|---|---|---|
checked |
bool |
false |
Initial checked state. Alpine owns it from there — add your own @click to sync Livewire. |
optimistic |
string|null |
null |
Livewire method to call on toggle, showing the new state before the server agrees. A refusal puts the old state back. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic call, after the new value — how one row of identical controls tells the server WHICH row. |
disabled |
bool |
false |
Disabled state |
shortcut |
string|null |
null |
Keyboard-shortcut hint rendered at the inline end. Display only — it does not bind the key. Hidden from screen readers: state a key your application binds with aria-keyshortcuts on the item, such as Control+N. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.radio-item>
| Prop | Type | Default | Description |
|---|---|---|---|
model |
string|null |
null |
Name of the Alpine variable holding the group's selected value. Set it on an ancestor x-data; every item naming it belongs to one group. |
value |
string|null |
null |
This option's value — what model is set to when the item is chosen |
disabled |
bool |
false |
Disabled state |
shortcut |
string|null |
null |
Keyboard-shortcut hint rendered at the inline end. Display only — it does not bind the key. Hidden from screen readers: state a key your application binds with aria-keyshortcuts on the item, such as Control+N. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::dropdown.submenu>
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
Parent item text (or pass a <x-slot:label> for rich content) |
icon |
string|null |
null |
Optional leading icon (WireKit icon alias) |
placement |
string |
'right-start' |
Floating UI placement of the child panel |
offset |
int |
0 |
Distance in px between the parent item and the child panel |
disabled |
bool |
false |
Disabled state |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
dropdown.trigger |
Wraps the element that opens the dropdown |
dropdown.indicator |
A chevron inside the trigger's button that turns while the menu is open |
dropdown.panel |
The floating menu container |
dropdown.item |
A single menu entry (link or action) |
dropdown.submenu |
A nested flyout sub-menu opened from a parent item |
dropdown.checkbox-item |
A self-toggling menuitemcheckbox. Takes checked and optimistic; it does not take href, danger, icon or active. |
dropdown.radio-item |
A menuitemradio in a group named by model. Same exclusions as checkbox-item. |
dropdown.separator |
Visual divider between item groups |
Accessibility
- In the quick form, an
aria-labeloraria-labelledbyof yours names the menu in place oflabel; a panel of your own is named by its ownlabel - Trigger:
aria-haspopup="true",aria-expanded(dynamic),aria-controls(linked to panel ID) - Panel:
role="menu", uniqueid(auto-generated) - Items:
role="menuitem",tabindex="-1" - Disabled items:
aria-disabled="true" - Focus management: first item is focused on open, focus returns to trigger on close
Keyboard Interaction
Opening the menu moves focus to its first row, and the arrow keys walk from there. They walk the
rows that are shown: a row hidden with x-show or hidden is stepped over, and Home and End
reach the first and last row shown.
| Key | Action |
|---|---|
Enter / Space / ArrowDown (on trigger) |
Open the menu and focus the first item |
ArrowUp / ArrowDown |
Move between items, wrapping at either end |
Home / End |
Jump to first / last item |
| Any letter or digit | Jump to the next item starting with it. Keep typing to narrow (co → com); press the same key again to cycle through every item sharing that letter. The buffer clears after half a second of no typing |
Enter / Space (on an item) |
Activate it and close the menu |
Escape |
Close the menu and return focus to the trigger |
Tab |
Close the menu and move focus onward |
ArrowRight (on a submenu parent) |
Open the submenu and focus its first item |
ArrowLeft / Escape (inside a submenu) |
Close the submenu and return focus to the parent item |
Checkbox and radio rows are part of the same walk — they carry menuitemcheckbox and
menuitemradio rather than menuitem, and the arrow keys treat all three alike. Disabled rows
are skipped.
Focus returns to the trigger whenever the menu is dismissed from inside it — Escape, Tab,
or activating a row. It deliberately does not return when you dismiss the menu by clicking
some other control: focus belongs where you just put it, and pulling it back to the trigger would
take it out of the field you clicked.
Type-ahead buffers what you type: a second character narrows to rows starting with both, and pressing the same key again cycles through everything sharing that letter instead. The buffer clears after half a second, so a pause starts a fresh search rather than extending the old one.
Pitfalls
- Don't put a
<x-wirekit::outside adropdown. item> <x-wirekit::. Items rely on the panel'sdropdown. panel> role="menu"for screen-reader semantics; orphaned items announce as plain links. - Don't use
<a>for triggers — use the provided<x-wirekit::. The component's Alpine code wires keyboard handlers (dropdown. trigger> Enter/Space/ArrowDown) onto the trigger element; a hand-rolled<a>will be reachable but not openable from the keyboard. - Don't combine
target="_blank"withwire:clickon the same item. WireKit auto-injectsrel="noopener noreferrer"for_blanklinks — combining it with a Livewire action is a UX trap (new tab + simultaneous server roundtrip in the source tab).
Design Tokens
| Token | Used for |
|---|---|
--font-wk-sans |
Panel + item font family |
--text-wk-md |
Item font size |
--color-wk-text |
Item text |
--color-wk-danger-text |
Destructive item text |
--color-wk-bg-elevated |
Panel background |
--color-wk-bg-hover |
Item hover background |
--color-wk-border / --color-wk-border-subtle |
Panel + separator border |
--border-wk-width |
Border width |
--radius-wk-md |
Panel border radius |
--shadow-wk-md |
Panel drop shadow |
--gap-wk-sm |
Item icon-to-label gap |
--padding-wk-x-md / --padding-wk-y-xs / --padding-wk-y-sm |
Panel + item padding |
--opacity-wk-disabled |
Disabled item visual weight |
--transition-wk-duration / --transition-wk-easing |
Open / close animation |
--z-wk-dropdown |
Panel stacking context |
Further Reading
- WAI-ARIA Menu Button Pattern — the authoring pattern this component implements
- Floating UI — positioning engine (bundled)
- MDN:
role="menu" - MDN:
aria-haspopup