Skip to content

overlays

Dropdown

Compound menu with keyboard navigation, left/right alignment, danger and disabled items, icons, and shortcut badges.

Copied!

Examples

Basic menu

Copied!
<x-ui.dropdown label="Actions">
    <x-ui.dropdown.item>Edit invoice</x-ui.dropdown.item>
    <x-ui.dropdown.item shortcut="⌘D">Duplicate</x-ui.dropdown.item>
    <x-ui.dropdown.separator />
    <x-ui.dropdown.item variant="danger">Void invoice</x-ui.dropdown.item>
</x-ui.dropdown>
<div
    class="relative inline-block text-left"
    x-data="{
        open: false,
        items() {
            return Array.from(this.$refs.menu.querySelectorAll('[role=menuitem]:not([disabled]):not([aria-disabled=true])'));
        },
        show() { this.open = true; this.$nextTick(() => this.items()[0]?.focus()); },
        hide() { this.open = false; this.$nextTick(() => this.$refs.trigger?.focus()); },
        toggle() { this.open ? this.hide() : this.show(); },
        focusByOffset(offset) {
            const list = this.items();
            const current = list.indexOf(document.activeElement);
            const next = (current + offset + list.length) % list.length;
            list[next]?.focus();
        },
        focusByTypeahead(char) {
            const list = this.items();
            if (!list.length || !char) return;
            const needle = char.toLowerCase();
            const current = list.indexOf(document.activeElement);
            const ordered = list.slice(current + 1).concat(list.slice(0, current + 1));
            ordered.find((el) => (el.textContent || '').trim().toLowerCase().startsWith(needle))?.focus();
        }
    }"
    x-on:click.outside="if (open) hide()"
    x-on:keydown.escape.window="if (open) hide()"
    x-on:keydown="
        if (!open) return;
        if ($event.key === 'ArrowDown') { $event.preventDefault(); focusByOffset(1); }
        else if ($event.key === 'ArrowUp') { $event.preventDefault(); focusByOffset(-1); }
        else if ($event.key.length === 1 && !$event.ctrlKey && !$event.metaKey && !$event.altKey) { $event.preventDefault(); focusByTypeahead($event.key); }
    "
>
    <button
        type="button"
        x-ref="trigger"
        class="inline-flex h-9 items-center gap-2 rounded-control border border-border bg-surface-raised px-3.5 text-sm font-medium text-fg shadow-2xs transition-colors duration-150 hover:border-border-strong hover:bg-surface-sunken motion-safe:active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
        aria-haspopup="menu"
        x-bind:aria-expanded="open.toString()"
        x-on:click="toggle()"
    >
        Actions
        <svg class="size-4 shrink-0 text-fg-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
            <path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
        </svg>
    </button>
    <div
        x-ref="menu"
        role="menu"
        x-show="open"
        x-cloak
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95 translate-y-0.5"
        x-transition:enter-end="opacity-100 scale-100 translate-y-0"
        x-transition:leave="transition ease-in duration-100"
        x-transition:leave-start="opacity-100 scale-100 translate-y-0"
        x-transition:leave-end="opacity-0 scale-95 translate-y-0.5"
        class="absolute left-0 z-40 mt-2 min-w-48 origin-top-left rounded-card border border-border bg-surface-raised p-1 shadow-lg ring-1 ring-border/60"
    >
        <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Edit invoice</button>
        <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">
            <span class="min-w-0 flex-1">Duplicate</span>
            <kbd class="ml-auto pl-4 font-mono text-[11px] tracking-tight text-fg-subtle">⌘D</kbd>
        </button>
        <div role="separator" class="my-1 h-px bg-border"></div>
        <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-danger hover:bg-danger-soft focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Void invoice</button>
    </div>
</div>

Right aligned

Copied!
<div class="flex justify-end">
    <x-ui.dropdown align="right" label="Staff account">
        <x-ui.dropdown.item>Profile & store access</x-ui.dropdown.item>
        <x-ui.dropdown.item>Billing · IDR</x-ui.dropdown.item>
        <x-ui.dropdown.separator />
        <x-ui.dropdown.item variant="danger">Sign out</x-ui.dropdown.item>
    </x-ui.dropdown>
</div>
<div class="flex justify-end">
    <div
        class="relative inline-block text-left"
        x-data="{
            open: false,
            show() { this.open = true },
            hide() { this.open = false; this.$nextTick(() => this.$refs.trigger?.focus()) },
            toggle() { this.open ? this.hide() : this.show() },
        }"
        x-on:click.outside="if (open) hide()"
        x-on:keydown.escape.window="if (open) hide()"
    >
        <button
            type="button"
            x-ref="trigger"
            class="inline-flex h-9 items-center gap-2 rounded-control border border-border bg-surface-raised px-3.5 text-sm font-medium text-fg shadow-2xs transition-colors duration-150 hover:border-border-strong hover:bg-surface-sunken motion-safe:active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
            aria-haspopup="menu"
            x-bind:aria-expanded="open.toString()"
            x-on:click="toggle()"
        >
            Staff account
            <svg class="size-4 shrink-0 text-fg-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
        </button>
        <div
            x-ref="menu"
            role="menu"
            x-show="open"
            x-cloak
            x-transition:enter="transition ease-out duration-150"
            x-transition:enter-start="opacity-0 scale-95 translate-y-0.5"
            x-transition:enter-end="opacity-100 scale-100 translate-y-0"
            x-transition:leave="transition ease-in duration-100"
            x-transition:leave-start="opacity-100 scale-100 translate-y-0"
            x-transition:leave-end="opacity-0 scale-95 translate-y-0.5"
            class="absolute right-0 z-40 mt-2 min-w-48 origin-top-right rounded-card border border-border bg-surface-raised p-1 shadow-lg ring-1 ring-border/60"
        >
            <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Profile & store access</button>
            <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Billing · IDR</button>
            <div class="my-1 h-px bg-border" role="separator"></div>
            <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-danger hover:bg-danger-soft focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Sign out</button>
        </div>
    </div>
</div>

Disabled item

Copied!
<x-ui.dropdown label="More">
    <x-ui.dropdown.item>Share link</x-ui.dropdown.item>
    <x-ui.dropdown.item variant="disabled">Archive (needs Owner)</x-ui.dropdown.item>
    <x-ui.dropdown.item>Export CSV</x-ui.dropdown.item>
</x-ui.dropdown>
<div
    class="relative inline-block text-left"
    x-data="{
        open: false,
        show() { this.open = true },
        hide() { this.open = false; this.$nextTick(() => this.$refs.trigger?.focus()) },
        toggle() { this.open ? this.hide() : this.show() },
    }"
    x-on:click.outside="if (open) hide()"
    x-on:keydown.escape.window="if (open) hide()"
>
    <button
        type="button"
        x-ref="trigger"
        class="inline-flex h-9 items-center gap-2 rounded-control border border-border bg-surface-raised px-3.5 text-sm font-medium text-fg shadow-2xs transition-colors duration-150 hover:border-border-strong hover:bg-surface-sunken focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
        aria-haspopup="menu"
        x-bind:aria-expanded="open.toString()"
        x-on:click="toggle()"
    >
        More
    </button>
    <div
        x-ref="menu"
        role="menu"
        x-show="open"
        x-cloak
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95 translate-y-0.5"
        x-transition:enter-end="opacity-100 scale-100 translate-y-0"
        x-transition:leave="transition ease-in duration-100"
        x-transition:leave-start="opacity-100 scale-100 translate-y-0"
        x-transition:leave-end="opacity-0 scale-95 translate-y-0.5"
        class="absolute left-0 z-40 mt-2 min-w-48 origin-top-left rounded-card border border-border bg-surface-raised p-1 shadow-lg ring-1 ring-border/60"
    >
        <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Share link</button>
        <button type="button" role="menuitem" tabindex="-1" disabled aria-disabled="true" class="flex min-h-10 w-full cursor-not-allowed items-center rounded-control px-3 py-2.5 text-left text-sm text-fg-subtle opacity-60">Archive (needs Owner)</button>
        <button type="button" role="menuitem" tabindex="-1" class="flex min-h-10 w-full items-center rounded-control px-3 py-2.5 text-left text-sm text-fg hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring" x-on:click="hide()" x-on:keydown.enter.prevent="hide()" x-on:keydown.space.prevent="hide()">Export CSV</button>
    </div>
</div>

Props API

Component props
Prop Description
align
Type left|right
Default left
Menu alignment relative to the trigger.
label
Type string
Default Open menu
Default trigger label when the trigger slot is omitted.
item.variant
Type default|danger|disabled
Default default
Visual and interactive treatment for dropdown items.
item.shortcut
Type string|null
Default null
Optional keyboard shortcut badge shown on the trailing edge.
item.href
Type string|null
Default null
When set, the item renders as an anchor menuitem.

Accessibility notes

Architected with WCAG 2.2 AA patterns in mind. These notes document the exact keyboard and ARIA behavior for this component.

  • Trigger exposes aria-haspopup="menu" and x-bind:aria-expanded. The menu uses role="menu" and items use role="menuitem".
  • ArrowDown and ArrowUp move between enabled items, skipping disabled ones. Home and End jump to the first and last enabled item.
  • Escape closes the menu and restores focus to the trigger. Tab closes without forcing focus restoration so natural tab order continues.
  • Click outside closes the menu. Opening with ArrowDown on the trigger focuses the first enabled item.
  • Disabled items set disabled and aria-disabled="true" and are excluded from the keyboard roving list.

Built by Praesepe

Architecture & custom software partner

Praesepe uses these exact UI primitives to ship SME dashboards, inventory systems, invoicing desks, and internal tools faster, without rebuilding a design system for every client.

  • Same Blade + Tailwind 4 + Alpine 3 stack you see in this docs site
  • Opinionated for Indonesian SME ops: invoices, stock, roles, approvals
  • Architected with WCAG 2.2 AA patterns in mind for keyboard and ARIA