Skip to content

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.

Account menuAn avatar trigger with a user header and 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.

Row actionsAn icon-led actions menu on a list row.
<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.

SelectionA sort control that reflects the current choice.
<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.

Disabled itemA non-selectable item, e.g. an action the user lacks permission for.
<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 ​

PropTypeDefaultDescription
placement"bottom-start" | "bottom-end" | "bottom" | "top-start" | "top-end" | "top""bottom-start"Where the menu opens relative to the trigger.
offsetnumber4Gap (px) between trigger and menu.
classstringnoneClasses merged onto the root.

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):
vue
<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 (your Button, or an avatar button), to avoid nesting a <button> inside a <button>:
vue
<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).

PropTypeDefaultDescription
asChildbooleanfalseProject menu behavior onto the child instead of rendering a button.
classstringnoneClasses merged onto the trigger (default mode only).
PropTypeDefaultDescription
size"fit" | "full" | "3xs" | "2xs" | "xs" | "sm" | "md" | "lg""full"Menu width. full matches the trigger's width.
classstringnoneClasses merged onto the panel.
PropTypeDefaultDescription
disabledbooleanfalseDisables the item: skipped by keyboard nav, emits no select.
classstringnoneClasses 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, Enter to select, Escape to close, focus trapping, and role="menu" / menuitem wiring are all handled.
  • The active item (hover or keyboard) is exposed via the active slot prop and styled with data-active.
  • Disabled items are correctly skipped by keyboard navigation.
  • The default-mode trigger shows a visible keyboard focus ring in the theme's --ring color.