Dropdown
The <x-wirekit::dropdown> component creates accessible dropdown menus with keyboard navigation, Floating UI positioning, and automatic flip/shift behavior.
Usage
Composition forms
<x-wirekit::dropdown> 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:
Explicit form — nest <x-wirekit::dropdown.trigger> and <x-wirekit::dropdown.panel> 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.
Use ONE form per dropdown, not both. The quick form's auto-wrap fires when the <x-slot:trigger> slot is present; mixing it with an explicit <x-wirekit::dropdown.panel> child produces a double-panel render.
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.checkbox-item— a self-togglingmenuitemcheckbox(Alpine owns the checked state, seeded from:checked). Add your own@clickto also sync Livewire.dropdown.radio-item— amenuitemradiowhose 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.
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.js alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></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. 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 component renders exactly as before, down to the byte.
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::dropdown.submenu> 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::dropdown.item>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::dropdown.submenu> inside another submenu's panel.
Behavior
- Click outside closes the dropdown automatically
- Floating UI (bundled ~3.5 KB) 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 |
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.panel>
| Prop | Type | Default | Description |
|---|---|---|---|
width |
string |
'auto' |
Panel width: 'auto', 'trigger', or any CSS value |
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') |
shortcut |
string|null |
null |
Keyboard-shortcut hint rendered at the inline end (e.g. ⌘K). Display only — it does not bind the key. |
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.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.separator |
Visual divider between item groups |
Accessibility
- 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.
| 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::dropdown.item>outside a<x-wirekit::dropdown.panel>. Items rely on the panel'srole="menu"for screen-reader semantics; orphaned items announce as plain links. - Don't use
<a>for triggers — use the provided<x-wirekit::dropdown.trigger>. The component's Alpine code wires keyboard handlers (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-subtle |
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, ~3.5 KB)
- MDN:
role="menu" - MDN:
aria-haspopup