Skip to main content
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

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::dropdown.panel>, because that panel is yours.

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.

Pick one form per dropdown

The quick form composes the trigger and the panel for you, so an explicit <x-wirekit::dropdown.panel> 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::dropdown.indicator /> inside the trigger's button, after its label, and the button shows a chevron that turns while the menu is open:

A workspace switcher that shows it opens a menu

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:

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. The hint is hidden from screen readers, so a key your application binds goes on the item as aria-keyshortcuts as well, such as aria-keyshortcuts="Control+N", which reaches its menuitem.
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') }}" 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.

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

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.

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.

Focus is not selection, and the menu no longer draws it like one

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-label or aria-labelledby of yours names the menu in place of label; a panel of your own is named by its own label
  • 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. 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::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-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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store