Dropdown
A menu of actions triggered by a button, for row actions, account menus, and "more options" overflows. Built on Headless UI's menu, it handles keyboard navigation (arrow keys, Enter, Escape), focus, and active-item highlighting for you.
Dropdown is a compound component composed of four parts.
Anatomy
Dropdown ← the root; controls placement and offset
├── DropdownTrigger ← the button that opens the menu
└── DropdownContent ← the floating menu panel
└── DropdownItem ← each selectable action (emits `select`)DropdownItem is slot-only: put a label, or an icon plus a label, inside it. It emits select when chosen, and exposes an active slot prop (true when highlighted by hover or keyboard).
Usage
The most familiar Dropdown is the account menu: an avatar trigger opening a menu with the signed-in user and their actions.
<script setup lang="ts">
import {
Dropdown,
DropdownTrigger,
DropdownContent,
DropdownItem,
Avatar,
Separator,
} from "@dlbcodes/ui";
import { PhUser, PhGear, PhCreditCard, PhSignOut } from "@phosphor-icons/vue";
</script>
<template>
<div>
<Dropdown placement="top-start">
<DropdownTrigger>
<Avatar
name="Daniel Lobo"
src="https://i.pravatar.cc/120?img=12"
size="base"
/>
</DropdownTrigger>
<DropdownContent size="sm">
<!-- A header row identifying the signed-in user -->
<div class="flex items-center gap-3 px-3 py-2">
<Avatar
name="Daniel Lobo"
src="https://i.pravatar.cc/120?img=12"
size="sm"
/>
<div class="min-w-0">
<div
class="truncate text-sm font-medium text-text-primary"
>
Daniel Lobo
</div>
<div class="truncate text-xs text-text-tertiary">
daniel@example.com
</div>
</div>
</div>
<Separator class="my-1" />
<DropdownItem @select="() => {}">
<PhUser class="size-4" /> Profile
</DropdownItem>
<DropdownItem @select="() => {}">
<PhGear class="size-4" /> Settings
</DropdownItem>
<DropdownItem @select="() => {}">
<PhCreditCard class="size-4" /> Billing
</DropdownItem>
<Separator class="my-1" />
<DropdownItem @select="() => {}">
<PhSignOut class="size-4" /> Sign out
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
</template>
With icons
Items take arbitrary slot content, so you can pair an icon with each label, as in a row-actions menu.
<script setup lang="ts">
import {
Dropdown,
DropdownTrigger,
DropdownContent,
DropdownItem,
Button,
} from "@dlbcodes/ui";
import {
PhPencilSimple,
PhCopy,
PhArchive,
PhTrash,
PhDotsThree,
} from "@phosphor-icons/vue";
</script>
<template>
<!-- A row-actions menu: the "⋯" you click on a list/table row -->
<div
class="flex w-full max-w-sm items-center justify-between gap-3 rounded-xl border border-border-subtle bg-bg-raised px-4 py-3"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-text-primary">
Q3 Report.pdf
</div>
<div class="truncate text-xs text-text-tertiary">
Updated 2 days ago
</div>
</div>
<Dropdown placement="bottom-start">
<DropdownTrigger as-child v-slot="{ open }">
<Button
variant="icon"
size="icon"
aria-label="Row actions"
:class="open ? 'bg-bg-subtle' : ''"
>
<PhDotsThree class="size-5" weight="bold" />
</Button>
</DropdownTrigger>
<DropdownContent size="sm">
<DropdownItem @select="() => {}">
<PhPencilSimple class="size-4" /> Rename
</DropdownItem>
<DropdownItem @select="() => {}">
<PhCopy class="size-4" /> Duplicate
</DropdownItem>
<DropdownItem @select="() => {}">
<PhArchive class="size-4" /> Archive
</DropdownItem>
<DropdownItem disabled>
<PhTrash class="size-4" /> Delete (no permission)
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
</template>
Handling selection
Listen for select on each item to react to a choice. The menu closes automatically when an item is chosen.
<script setup lang="ts">
import { ref } from "vue";
import {
Dropdown,
DropdownTrigger,
DropdownContent,
DropdownItem,
Button,
} from "@dlbcodes/ui";
import { PhCaretDown, PhCheck } from "@phosphor-icons/vue";
const options = ["Newest", "Oldest", "Most popular"] as const;
const sort = ref<(typeof options)[number]>("Newest");
</script>
<template>
<div class="flex flex-col items-start gap-3">
<Dropdown>
<DropdownTrigger as-child>
<Button variant="outline">
Sort: {{ sort }}
<PhCaretDown class="size-4" />
</Button>
</DropdownTrigger>
<DropdownContent size="3xs">
<DropdownItem
v-for="opt in options"
:key="opt"
@select="sort = opt"
class="justify-between"
>
<span>{{ opt }}</span>
<PhCheck
class="size-4"
:class="opt === sort ? 'opacity-100' : 'opacity-0'"
/>
</DropdownItem>
</DropdownContent>
</Dropdown>
<div class="text-sm text-text-tertiary">
Showing results sorted by
<span class="text-text-secondary">{{ sort }}</span
>.
</div>
</div>
</template>
Disabled items
Mark an item disabled to make it non-interactive. It is skipped by keyboard navigation and does not emit select.
<script setup lang="ts">
import {
Dropdown,
DropdownTrigger,
DropdownContent,
DropdownItem,
Button,
} from "@dlbcodes/ui";
</script>
<template>
<Dropdown>
<DropdownTrigger as-child>
<Button variant="outline">Actions</Button>
</DropdownTrigger>
<DropdownContent size="sm">
<DropdownItem>Edit</DropdownItem>
<DropdownItem>Duplicate</DropdownItem>
<DropdownItem disabled>Delete (no permission)</DropdownItem>
</DropdownContent>
</Dropdown>
</template>
Appearance
The menu panel uses the theme's --popover surface and --border, and the active item is highlighted with --accent, so a dropdown follows your theme, including dark mode. Its corners follow --radius. See Theming.
Props
Dropdown
| Prop | Type | Default | Description |
|---|---|---|---|
placement | "bottom-start" | "bottom-end" | "bottom" | "top-start" | "top-end" | "top" | "bottom-start" | Where the menu opens relative to the trigger. |
offset | number | 4 | Gap (px) between trigger and menu. |
class | string | none | Classes merged onto the root. |
DropdownTrigger
The button that opens the menu. It has two modes:
- Default: the trigger renders its own button element, and the slot is its content. Use this for bare content (a label, or an icon plus label):
<DropdownTrigger class="...">Options</DropdownTrigger>asChild: the trigger does not render its own button; it projects the menu behavior onto the single child you provide. Use this when the child is already an interactive element (yourButton, or an avatar button), to avoid nesting a<button>inside a<button>:
<DropdownTrigger as-child>
<Button variant="outline">Options</Button>
</DropdownTrigger>In asChild mode the child must be a single root element that forwards attributes (most components do by default).
Exposes open (boolean) via its slot. Accepts class (applied to the trigger's own button in default mode).
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Project menu behavior onto the child instead of rendering a button. |
class | string | none | Classes merged onto the trigger (default mode only). |
DropdownContent
| Prop | Type | Default | Description |
|---|---|---|---|
size | "fit" | "full" | "3xs" | "2xs" | "xs" | "sm" | "md" | "lg" | "full" | Menu width. full matches the trigger's width. |
class | string | none | Classes merged onto the panel. |
DropdownItem
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disables the item: skipped by keyboard nav, emits no select. |
class | string | none | Classes merged onto the item. |
Emits select when chosen. Exposes active (boolean) via its slot.
Accessibility
- Built on Headless UI's
Menu, so arrow-key navigation, type-ahead,Enterto select,Escapeto close, focus trapping, androle="menu"/menuitemwiring are all handled. - The active item (hover or keyboard) is exposed via the
activeslot prop and styled withdata-active. - Disabled items are correctly skipped by keyboard navigation.
- The default-mode trigger shows a visible keyboard focus ring in the theme's
--ringcolor.