Dropdown menu
A menu of actions, checks and choices that opens from a trigger.
Vibra draws the menu and its submenus as the kit's elev-1 floating layer and fades them in over --duration-base without shadcn's zoom, sliding 4px from their side rather than 8; items are set in the 13px label register. Where shadcn's items name physical sides, Vibra's are logical — the tick, the shortcut and a submenu's chevron sit at the inline end, and a submenu opens towards it — so under DirectionProvider a right-to-left menu mirrors whole and its arrow keys turn with it; the popup is portalled, so it carries dir itself. Base UI gives it the full keyboard: arrows, Home and End, typeahead, Escape, and the focus back on the trigger. Open it from a Button with render on the trigger, and name an icon-only trigger for the row it acts on.
Install
npx shadcn@latest add @vibra/dropdown-menuNeeds the @vibra registry in your components.json — set it up once.
Examples
import { CopyIcon, DownloadIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function DropdownMenuDemo() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="Report actions" />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>Weekly revenue</DropdownMenuLabel>
<DropdownMenuItem>
<PencilIcon aria-hidden="true" />
Rename
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon aria-hidden="true" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon aria-hidden="true" />
Export as CSV
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Row actions
Each row's button is named for its invoice. A reminder can't go to a paid one: the item stays, dimmed but reachable, and says why.
import { BanIcon, BellRingIcon, DownloadIcon, EllipsisIcon, EyeIcon, Undo2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { StatusBadge } from "@/components/ui/status-badge"
const INVOICES = [
{ id: "INV-2041", customer: "Harbour Logistics", status: "overdue", paidOn: null },
{ id: "INV-2040", customer: "Keystone Dental", status: "paid", paidOn: "Sep 12" },
{ id: "INV-2039", customer: "Riverside Florists", status: "pending", paidOn: null },
]
// Each row's button is named for its row, so a screen reader hears which
// invoice it would act on. A reminder can't go to a paid invoice: the item
// stays in the menu, focusable and dimmed, and its second line says why.
// Escape, or choosing an item, hands the focus back to the row's button.
export default function DropdownMenuRowActions() {
return (
<ul aria-label="Invoices" className="w-full max-w-md divide-y divide-border rounded-lg border border-border">
{INVOICES.map((invoice) => (
<li key={invoice.id} className="flex items-center gap-3 px-3 py-2">
<span className="font-mono text-sm">{invoice.id}</span>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{invoice.customer}</span>
<StatusBadge status={invoice.status} size="sm" />
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for ${invoice.id}`} />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem render={<a href={`/billing/invoices/${invoice.id}`} />}>
<EyeIcon aria-hidden="true" />
View invoice
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon aria-hidden="true" />
Download PDF
</DropdownMenuItem>
<DropdownMenuItem disabled={invoice.paidOn !== null} className="items-start">
<BellRingIcon aria-hidden="true" className="mt-0.5" />
<span className="flex flex-col">
Send a reminder{" "}
{invoice.paidOn ? <span className="text-xs text-muted-foreground">Paid on {invoice.paidOn}</span> : null}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{invoice.paidOn ? (
<DropdownMenuItem variant="destructive">
<Undo2Icon aria-hidden="true" />
Refund payment…
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive">
<BanIcon aria-hidden="true" />
Void invoice…
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)
}Checkbox items
Which columns a table shows: toggles that keep the menu open, one that can't be hidden and says so, and a count on the trigger.
"use client"
import * as React from "react"
import { Columns3Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const COLUMNS = ["Customer", "Status", "Due date", "Amount", "Owner"] as const
// Which columns the orders table shows. Checkbox items stay open when
// toggled, so several can change in one visit; Space toggles the highlighted
// one. The order number can't be hidden, and says so in its own line.
export default function DropdownMenuColumns() {
const [shown, setShown] = React.useState<string[]>(["Customer", "Status", "Amount"])
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<Columns3Icon aria-hidden="true" data-icon="inline-start" />
Columns{" "}
<span className="text-muted-foreground tabular-nums">{shown.length + 1}</span>{" "}
<span className="sr-only">shown</span>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Show in the orders table</DropdownMenuLabel>
<DropdownMenuCheckboxItem checked disabled className="items-start">
<span className="flex flex-col">
Order number{" "}
<span className="text-xs text-muted-foreground">Always shown</span>
</span>
</DropdownMenuCheckboxItem>
{COLUMNS.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={shown.includes(column)}
onCheckedChange={(checked) =>
setShown((current) => (checked ? [...current, column] : current.filter((name) => name !== column)))
}
>
{column}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}Radio items
One sort order from four, ticked; the trigger names it, typing a letter jumps, and choosing closes the menu.
"use client"
import * as React from "react"
import { ArrowDownUpIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const ORDERS = [
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "amount", label: "Largest amount" },
{ value: "customer", label: "Customer, A to Z" },
]
// One choice from a few: radio items, the chosen one ticked. The trigger says
// the current order, so the reader knows it before opening the menu; typing a
// letter jumps to the item that starts with it, and choosing one closes the
// menu (closeOnClick — radio items stay open by default).
export default function DropdownMenuSort() {
const [order, setOrder] = React.useState("newest")
const current = ORDERS.find((option) => option.value === order)!
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<ArrowDownUpIcon aria-hidden="true" data-icon="inline-start" />
Sort: {current.label}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuRadioGroup value={order} onValueChange={(value) => setOrder(String(value))}>
<DropdownMenuLabel>Sort orders by</DropdownMenuLabel>
{ORDERS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}Submenus
Moving and sharing each hold a list one level down: Right arrow opens it, Left arrow or Escape comes back.
"use client"
import * as React from "react"
import { EllipsisIcon, FolderIcon, FolderInputIcon, LinkIcon, MailIcon, PencilIcon, Share2Icon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const FOLDERS = ["Finance", "Board packs", "Archive"]
// Moving and sharing each hold their own list, one level down. Right arrow
// (or hovering) opens a submenu and puts the focus in it; Left arrow or
// Escape closes it and returns to the item that opened it.
export default function DropdownMenuSubmenu() {
const [folder, setFolder] = React.useState("Reports")
return (
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2">
<span className="flex min-w-0 flex-col text-sm">
<span className="truncate font-medium">Q3 revenue report.pdf</span>
<span aria-live="polite" className="text-xs text-muted-foreground">
In {folder}
</span>
</span>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Actions for Q3 revenue report.pdf" />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>
<PencilIcon aria-hidden="true" />
Rename
<DropdownMenuShortcut>F2</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInputIcon aria-hidden="true" />
Move to
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-40">
{FOLDERS.map((name) => (
<DropdownMenuItem key={name} disabled={name === folder} onClick={() => setFolder(name)}>
<FolderIcon aria-hidden="true" />
{name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Share2Icon aria-hidden="true" />
Share
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
<DropdownMenuItem>
<LinkIcon aria-hidden="true" />
Copy link
</DropdownMenuItem>
<DropdownMenuItem>
<MailIcon aria-hidden="true" />
Email to the board
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}Workspace switcher
The current workspace named on the trigger and ticked on the brand tint in the menu. WorkspaceSwitcher is this, ready-made.
"use client"
import * as React from "react"
import { ChevronsUpDownIcon, PlusIcon, SettingsIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const WORKSPACES = [
{ id: "northwind", name: "Northwind", plan: "Scale" },
{ id: "northwind-eu", name: "Northwind Europe", plan: "Growth" },
{ id: "harbour", name: "Harbour sandbox", plan: "Starter" },
]
function Mark({ name }: { name: string }) {
return (
<span aria-hidden="true" className="flex size-5 shrink-0 items-center justify-center rounded-md bg-secondary text-avatar font-semibold text-secondary-foreground">
{getInitials(name)}
</span>
)
}
// Where you are, and where else you could be. The trigger names the current
// workspace; in the menu it is the checked radio, on the brand tint and in
// the heavier weight. Choosing one is a destination, so the menu closes.
export default function DropdownMenuWorkspace() {
const [active, setActive] = React.useState("northwind")
const current = WORKSPACES.find((workspace) => workspace.id === active)!
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" className="w-60 justify-between ps-1.5" aria-label={`Workspace: ${current.name}`} />}>
<span className="flex min-w-0 items-center gap-2">
<Mark name={current.name} />
<span className="truncate">{current.name}</span>
</span>
<ChevronsUpDownIcon aria-hidden="true" className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60">
<DropdownMenuRadioGroup value={active} onValueChange={(value) => setActive(String(value))}>
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{WORKSPACES.map((workspace) => (
<DropdownMenuRadioItem key={workspace.id} value={workspace.id} closeOnClick className="data-checked:font-medium data-checked:not-focus:bg-brand-muted">
<Mark name={workspace.name} />
<span className="truncate">{workspace.name}</span>{" "}
<span className="ms-auto text-xs font-normal text-muted-foreground">{workspace.plan}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PlusIcon aria-hidden="true" />
Create a workspace
</DropdownMenuItem>
<DropdownMenuItem render={<a href="/settings/workspace" />}>
<SettingsIcon aria-hidden="true" />
Workspace settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Account menu
A face for a trigger, named for what it opens; the label says whose account it is, and the pages are real links.
import { CreditCardIcon, KeyboardIcon, LogOutIcon, UserIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const NAME = "Saoirse Byrne"
// The face is the trigger, so the button is named for what it opens. The
// label says whose account this is before anything in it can be chosen, and
// the pages are real links: they open in a new tab like any other.
export default function DropdownMenuAccount() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="rounded-full" aria-label={`Account menu for ${NAME}`} />}>
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(NAME)} alt="" />
<AvatarFallback>{getInitials(NAME)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5 text-sm font-medium text-foreground">
{NAME}
<span className="text-xs font-normal text-muted-foreground">saoirse@northwind.example</span>
</DropdownMenuLabel>
<DropdownMenuItem render={<a href="/account/profile" />}>
<UserIcon aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem render={<a href="/billing" />}>
<CreditCardIcon aria-hidden="true" />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<KeyboardIcon aria-hidden="true" />
Keyboard shortcuts
<DropdownMenuShortcut>?</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Items with descriptions
A tile, a title and a quieter line, one item each; label keeps typeahead on the title.
import { ChartLineIcon, GaugeIcon, PlusIcon, StickyNoteIcon, TableIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const WIDGETS = [
{ icon: GaugeIcon, title: "Figure", body: "One number and its change" },
{ icon: ChartLineIcon, title: "Line chart", body: "A metric over time" },
{ icon: TableIcon, title: "Table", body: "Rows from a saved view" },
{ icon: StickyNoteIcon, title: "Note", body: "Text for the team" },
]
// Items that need a word of explanation: a tile, a title and a quieter line.
// The two lines are one item — one stop for the arrow keys, read as a single
// name — and typing its first letter still jumps to it.
export default function DropdownMenuRich() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<PlusIcon aria-hidden="true" data-icon="inline-start" />
Add a widget
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64">
<DropdownMenuGroup>
<DropdownMenuLabel>Add to Revenue overview</DropdownMenuLabel>
{WIDGETS.map(({ icon: Icon, title, body }) => (
<DropdownMenuItem key={title} label={title} className="gap-3 py-1.5">
<span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon />
</span>
<span className="flex flex-col">
<span className="font-medium">{title}</span> <span className="text-xs text-muted-foreground">{body}</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}Links
Every item goes somewhere, so every item is an anchor; the one that leaves the app says so in its name.
import { BookOpenIcon, CircleHelpIcon, ExternalLinkIcon, LifeBuoyIcon, MegaphoneIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Every item goes somewhere, so every item is a link: Enter follows it, and
// the browser's own open-in-a-new-tab still works on it. The one that leaves
// the app says so in its name as well as with its icon.
export default function DropdownMenuHelp() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Help" />}>
<CircleHelpIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel>Need a hand?</DropdownMenuLabel>
<DropdownMenuItem render={<a href="https://docs.northwind.example" target="_blank" rel="noreferrer" />}>
<BookOpenIcon aria-hidden="true" />
Documentation <span className="sr-only">(opens in a new tab)</span>
<ExternalLinkIcon aria-hidden="true" className="ms-auto text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuItem render={<a href="/changelog" />}>
<MegaphoneIcon aria-hidden="true" />
What’s new
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem render={<a href="mailto:support@northwind.example" />}>
<LifeBuoyIcon aria-hidden="true" />
Email support
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Right to left
Icons lead at the right, shortcuts and the tick trail at the left, and the submenu opens to the left — the Left arrow opens it.
"use client"
import * as React from "react"
import { ArchiveIcon, EllipsisIcon, FolderInputIcon, PencilIcon, Trash2Icon, WalletIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DirectionProvider } from "@/components/ui/direction"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Read from the right: icons lead at the right, shortcuts and the tick trail
// at the left, and the submenu's chevron points left, the way it opens.
// DirectionProvider tells Base UI, so the Left arrow opens the submenu and the
// Right arrow closes it; the menu is portalled, so it carries dir itself.
export default function DropdownMenuRtl() {
const [pinned, setPinned] = React.useState(true)
return (
<DirectionProvider direction="rtl">
<div dir="rtl" lang="ar">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="إجراءات التقرير" />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" dir="rtl" lang="ar" className="w-52">
<DropdownMenuItem>
<PencilIcon aria-hidden="true" />
إعادة التسمية
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInputIcon aria-hidden="true" />
نقل إلى
</DropdownMenuSubTrigger>
<DropdownMenuSubContent dir="rtl" lang="ar" className="w-36">
<DropdownMenuItem>
<WalletIcon aria-hidden="true" />
المالية
</DropdownMenuItem>
<DropdownMenuItem>
<ArchiveIcon aria-hidden="true" />
الأرشيف
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuCheckboxItem checked={pinned} onCheckedChange={setPinned}>
تثبيت في لوحة التحكم
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
حذف التقرير
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DirectionProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| DropdownMenuItem.variant | "default" | "destructive" | "default" | destructive sets the item in the danger tone. |
| DropdownMenuItem.inset | boolean | — | Indents the item to line up with items that carry an icon or a check. |
| DropdownMenuItem.disabled | boolean | false | The item dims and can't be chosen, but the arrow keys still reach it — say why on its second line. |
| DropdownMenuItem.render | React.ReactElement | — | Renders the item as another element — <a href> for an item that goes somewhere, so it is a real link. |
| DropdownMenuItem.label | string | — | The words typeahead matches, for an item whose content is more than its name. |
| DropdownMenuCheckboxItem.checked / onCheckedChange | boolean / (checked: boolean) => void | — | A toggle. The menu stays open, so several can change in one visit. |
| DropdownMenuRadioItem.closeOnClick | boolean | false | Radio items keep the menu open. Pass it when the choice ends the task — a sort order, a workspace. |
| DropdownMenuSubContent.side | "inline-end" | "inline-start" | "left" | "right" | … | "inline-end" | Where a submenu opens: away from its trigger, in either reading direction. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto elev-1 p-1 text-popover-foreground outline-none data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-start-1 data-[side=inline-start]:slide-in-from-end-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
{/* At the inline end, pointing the way the submenu opens: to the right,
or to the left in a right-to-left menu. */}
<ChevronRightIcon className="ms-auto rtl:rotate-180" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
// Logical, so a submenu opens away from its trigger in either direction;
// "right" opened it back over the menu's start in a right-to-left page.
side = "inline-end",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] elev-1 p-1 text-popover-foreground data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-start-1 data-[side=inline-start]:slide-in-from-end-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute end-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute end-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}