A menu of actions opened from a button, with submenus, checkbox and radio items, keyboard shortcut hints and type-ahead.
pnpm dlx shadcn@latest add @latheui/dropdown-menuFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { DropdownMenu as MenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { fade, spring, useMotionSafe } from "@/lib/motion"
import { Kbd } from "@/components/ui/kbd"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
const OpenContext = React.createContext(false)
// The trigger, and whether the menu was last used with a pointer. Closing it then puts focus back
// on the trigger without the keyboard focus ring, which would otherwise linger after a click.
const FocusContext = React.createContext<{
setTrigger: (node: HTMLButtonElement | null) => void
setByPointer: (byPointer: boolean) => void
/** Called as the menu closes. Returns true when it moved focus itself. */
restore: () => boolean
} | null>(null)
export type DropdownMenuProps = Omit<
React.ComponentProps<typeof MenuPrimitive.Root>,
"open" | "defaultOpen" | "onOpenChange"
> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
function DropdownMenu({
open: openProp,
defaultOpen = false,
onOpenChange,
...props
}: DropdownMenuProps) {
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
const trigger = React.useRef<HTMLButtonElement>(null)
const byPointer = React.useRef(false)
const focus = React.useMemo(
() => ({
setTrigger: (node: HTMLButtonElement | null) => {
trigger.current = node
},
setByPointer: (value: boolean) => {
byPointer.current = value
},
restore: () => {
if (!byPointer.current || !trigger.current) return false
trigger.current.focus({ preventScroll: true, focusVisible: false } as FocusOptions)
return true
},
}),
[]
)
return (
<OpenContext.Provider value={open}>
<FocusContext.Provider value={focus}>
<MenuPrimitive.Root open={open} onOpenChange={setOpen} {...props} />
</FocusContext.Provider>
</OpenContext.Provider>
)
}
function DropdownMenuTrigger({
ref,
onPointerDown,
onKeyDown,
...props
}: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
const focus = React.useContext(FocusContext)
const setRef = React.useCallback(
(node: HTMLButtonElement | null) => {
focus?.setTrigger(node)
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[focus, ref]
)
return (
<MenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
ref={setRef}
onPointerDown={(event) => {
focus?.setByPointer(true)
onPointerDown?.(event)
}}
onKeyDown={(event) => {
focus?.setByPointer(false)
onKeyDown?.(event)
}}
{...props}
/>
)
}
function DropdownMenuContent({
className,
children,
sideOffset = 6,
align = "start",
onCloseAutoFocus,
onPointerDownCapture,
onKeyDownCapture,
onPointerDownOutside,
...props
}: React.ComponentProps<typeof MenuPrimitive.Content>) {
const open = React.useContext(OpenContext)
const focus = React.useContext(FocusContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && (
<MenuPrimitive.Portal forceMount>
<MenuPrimitive.Content
{...slot}
forceMount
sideOffset={sideOffset}
align={align}
collisionPadding={8}
onPointerDownCapture={(event) => {
focus?.setByPointer(true)
onPointerDownCapture?.(event)
}}
onKeyDownCapture={(event) => {
focus?.setByPointer(false)
onKeyDownCapture?.(event)
}}
onPointerDownOutside={(event) => {
focus?.setByPointer(true)
onPointerDownOutside?.(event)
}}
onCloseAutoFocus={(event) => {
onCloseAutoFocus?.(event)
if (!event.defaultPrevented && focus?.restore()) event.preventDefault()
}}
{...props}
>
<motion.div
data-slot="dropdown-menu-content"
initial={{ opacity: 0, scale: reduced ? 1 : 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: fade }}
transition={safe(spring.snappy)}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-44 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5 outline-none",
className
)}
>
{children}
</motion.div>
</MenuPrimitive.Content>
</MenuPrimitive.Portal>
)}
</AnimatePresence>
)
}
const itemClass =
"relative flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
export type DropdownMenuItemProps = React.ComponentProps<typeof MenuPrimitive.Item> & {
/** Styles the item as a destructive action. */
destructive?: boolean
/** Keyboard shortcut hint, shown right-aligned, for example `"mod+d"`. Display only. */
shortcut?: string | string[]
}
function DropdownMenuItem({
className,
destructive = false,
shortcut,
children,
...props
}: DropdownMenuItemProps) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
className={cn(
itemClass,
destructive &&
"text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive [&_svg]:text-destructive",
className
)}
{...props}
>
{children}
{shortcut && <Kbd keys={shortcut} variant="ghost" className="ml-auto pl-4" />}
</MenuPrimitive.Item>
)
}
function Indicator() {
return (
<span className="ml-auto grid size-4 place-items-center pl-2">
<MenuPrimitive.ItemIndicator>
<CheckIcon aria-hidden="true" className="text-foreground!" />
</MenuPrimitive.ItemIndicator>
</span>
)
}
function DropdownMenuCheckboxItem({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem>) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(itemClass, className)}
{...props}
>
{children}
<Indicator />
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem>) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(itemClass, className)}
{...props}
>
{children}
<Indicator />
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Label>) {
return (
<MenuPrimitive.Label
data-slot="dropdown-menu-label"
className={cn("px-2 pt-1.5 pb-1 text-xs font-medium text-muted-foreground", className)}
{...props}
/>
)
}
const SubOpenContext = React.createContext(false)
export type DropdownMenuSubProps = Omit<
React.ComponentProps<typeof MenuPrimitive.Sub>,
"open" | "defaultOpen" | "onOpenChange"
> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
/**
* A nested menu. Moving the pointer diagonally from its trigger toward it keeps it open, even across
* other items: Radix tracks a safe area between the pointer and the submenu.
*/
function DropdownMenuSub({ open: openProp, defaultOpen = false, onOpenChange, ...props }: DropdownMenuSubProps) {
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
return (
<SubOpenContext.Provider value={open}>
<MenuPrimitive.Sub open={open} onOpenChange={setOpen} {...props} />
</SubOpenContext.Provider>
)
}
function DropdownMenuSubTrigger({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubTrigger>) {
return (
<MenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
className={cn(itemClass, "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className)}
{...props}
>
{children}
<ChevronRightIcon aria-hidden="true" className="ml-auto size-3.5!" />
</MenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
children,
sideOffset = 4,
// Lines the submenu's first item up with its trigger: the content's border and padding.
alignOffset = -5,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubContent>) {
const open = React.useContext(SubOpenContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && (
<MenuPrimitive.Portal forceMount>
<MenuPrimitive.SubContent
{...slot}
forceMount
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionPadding={8}
{...props}
>
<motion.div
data-slot="dropdown-menu-sub-content"
initial={{ opacity: 0, scale: reduced ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.98, transition: fade }}
transition={safe(spring.snappy)}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-40 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5 outline-none",
className
)}
>
{children}
</motion.div>
</MenuPrimitive.SubContent>
</MenuPrimitive.Portal>
)}
</AnimatePresence>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}Depends on
Also accepts Radix DropdownMenu.Root props such as modal and dir.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the menu opens or closes. |
Also accepts Radix DropdownMenu.Content props.
| Prop | Type | Default | Description |
|---|---|---|---|
| align | "start" | "center" | "end" | "start" | Alignment against the trigger. |
| side | "top" | "right" | "bottom" | "left" | "bottom" | Preferred side. Flips when there is no room. |
| sideOffset | number | 6 | Gap between trigger and menu in pixels. |
Also accepts Radix DropdownMenu.Item props.
| Prop | Type | Default | Description |
|---|---|---|---|
| onSelect | (event: Event) => void | — | Called when the item is chosen. Call event.preventDefault() to keep the menu open. |
| destructive | boolean | false | Styles the item as a destructive action. |
| shortcut | string | string[] | — | Shortcut hint on the right, drawn with Kbd: "mod+d" shows ⌘D on Apple platforms and Ctrl+D elsewhere. Display only; bind the key yourself. |
| disabled | boolean | false | Skipped by pointer and keyboard. |
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | "indeterminate" | — | Checked state. |
| onCheckedChange | (checked: boolean) => void | — | Called when toggled. |
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | On the group: the selected value. On an item: its value. |
| onValueChange | (value: string) => void | — | On the group: called when the selection changes. |
Wraps a DropdownMenuSubTrigger and its DropdownMenuSubContent. Also accepts Radix DropdownMenu.Sub props.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state of the submenu. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the submenu opens or closes. |
Also accepts Radix DropdownMenu.SubContent props.
| Prop | Type | Default | Description |
|---|---|---|---|
| sideOffset | number | 4 | Gap between the parent menu and the submenu in pixels. |
| alignOffset | number | -5 | Shifts the submenu so its first item lines up with the trigger. |
A non-interactive heading and a divider.
<DropdownMenuContent align="end" className="w-56">…</DropdownMenuContent>