Skip to main content
WireKit
Copy for LLM

Dropdown

The <x-wirekit::dropdown> component creates accessible dropdown menus with keyboard navigation, Floating UI positioning, and automatic flip/shift behavior.

Usage

Dropdown Menu

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:

Dropdown quick form

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:

Danger item

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":

Disabled item

Checkbox, Radio & Shortcut Items

Beyond plain items, the dropdown ships menu primitives that match the WAI-ARIA Menu pattern:

  • dropdown.checkbox-item — a self-toggling menuitemcheckbox (Alpine owns the checked state, seeded from :checked). Add your own @click to also sync Livewire.
  • dropdown.radio-item — a menuitemradio whose selection is coordinated by a shared Alpine variable named via model (set on an ancestor x-data).
  • shortcut prop — a keyboard-shortcut hint pinned to the inline-end of any item.
View menu with checkboxes, radios, and shortcuts

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.

Optimistic menu items — accepted, refused, and a slow answer

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.

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.

Dropdown with a submenu

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", unique id (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 (cocom); 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's role="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" with wire:click on the same item. WireKit auto-injects rel="noopener noreferrer" for _blank links — 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

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store