Dropdown menu
The ARIA menu-button pattern with grouped items, roving focus, shortcuts and destructive styling.
Live preview
Source
The exact file rendered in the preview above.
'use client'
import { ChevronDown, Copy, ExternalLink, Pencil, Settings, Trash2, UserPlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dropdown } from '@/components/ui/dropdown'
import { DemoRow, DemoStage } from './_kit'
export default function DropdownMenuDemo() {
return (
<DemoStage>
<DemoRow
label="Grouped menu"
description="ArrowDown opens on the first item, ArrowUp on the last."
>
<Dropdown
label="Project actions"
groups={[
{
items: [
{
id: 'rename',
label: 'Rename',
icon: <Pencil className="size-4" />,
meta: 'R',
onSelect: () => {},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: <Copy className="size-4" />,
meta: '⌘D',
onSelect: () => {},
},
],
},
{
label: 'Access',
items: [
{
id: 'invite',
label: 'Invite member',
icon: <UserPlus className="size-4" />,
onSelect: () => {},
},
{
id: 'settings',
label: 'Project settings',
icon: <Settings className="size-4" />,
href: '/components',
},
],
},
{
items: [
{
id: 'delete',
label: 'Delete project',
icon: <Trash2 className="size-4" />,
tone: 'danger',
onSelect: () => {},
},
],
},
]}
trigger={(props) => (
<Button variant="outline" trailingIcon={<ChevronDown className="size-4" />} {...props}>
Actions
</Button>
)}
/>
<Dropdown
align="end"
label="Account"
groups={[
{
items: [
{ id: 'profile', label: 'Profile', href: '/components/avatar' },
{
id: 'docs',
label: 'Documentation',
icon: <ExternalLink className="size-4" />,
href: '/docs',
},
],
},
{
items: [{ id: 'signout', label: 'Sign out', onSelect: () => {} }],
},
]}
trigger={(props) => (
<Button variant="ghost" {...props}>
End-aligned
</Button>
)}
/>
</DemoRow>
<DemoRow label="With a disabled item">
<Dropdown
label="Export"
groups={[
{
items: [
{ id: 'csv', label: 'Export as CSV', onSelect: () => {} },
{ id: 'json', label: 'Export as JSON', onSelect: () => {} },
{
id: 'pdf',
label: 'Export as PDF',
disabled: true,
meta: 'Business',
onSelect: () => {},
},
],
},
]}
trigger={(props) => (
<Button variant="soft" trailingIcon={<ChevronDown className="size-4" />} {...props}>
Export
</Button>
)}
/>
</DemoRow>
</DemoStage>
)
}
components/demos/dropdown-menu.tsx
'use client'
import { ChevronDown, Copy, ExternalLink, Pencil, Settings, Trash2, UserPlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dropdown } from '@/components/ui/dropdown'
import { DemoRow, DemoStage } from './_kit'
export default function DropdownMenuDemo() {
return (
<DemoStage>
<DemoRow
label="Grouped menu"
description="ArrowDown opens on the first item, ArrowUp on the last."
>
<Dropdown
label="Project actions"
groups={[
{
items: [
{
id: 'rename',
label: 'Rename',
icon: <Pencil className="size-4" />,
meta: 'R',
onSelect: () => {},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: <Copy className="size-4" />,
meta: '⌘D',
onSelect: () => {},
},
],
},
{
label: 'Access',
items: [
{
id: 'invite',
label: 'Invite member',
icon: <UserPlus className="size-4" />,
onSelect: () => {},
},
{
id: 'settings',
label: 'Project settings',
icon: <Settings className="size-4" />,
href: '/components',
},
],
},
{
items: [
{
id: 'delete',
label: 'Delete project',
icon: <Trash2 className="size-4" />,
tone: 'danger',
onSelect: () => {},
},
],
},
]}
trigger={(props) => (
<Button variant="outline" trailingIcon={<ChevronDown className="size-4" />} {...props}>
Actions
</Button>
)}
/>
<Dropdown
align="end"
label="Account"
groups={[
{
items: [
{ id: 'profile', label: 'Profile', href: '/components/avatar' },
{
id: 'docs',
label: 'Documentation',
icon: <ExternalLink className="size-4" />,
href: '/docs',
},
],
},
{
items: [{ id: 'signout', label: 'Sign out', onSelect: () => {} }],
},
]}
trigger={(props) => (
<Button variant="ghost" {...props}>
End-aligned
</Button>
)}
/>
</DemoRow>
<DemoRow label="With a disabled item">
<Dropdown
label="Export"
groups={[
{
items: [
{ id: 'csv', label: 'Export as CSV', onSelect: () => {} },
{ id: 'json', label: 'Export as JSON', onSelect: () => {} },
{
id: 'pdf',
label: 'Export as PDF',
disabled: true,
meta: 'Business',
onSelect: () => {},
},
],
},
]}
trigger={(props) => (
<Button variant="soft" trailingIcon={<ChevronDown className="size-4" />} {...props}>
Export
</Button>
)}
/>
</DemoRow>
</DemoStage>
)
}
components/ui/dropdown.tsx
'use client'
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
/**
* Dropdown menu
*
* The ARIA menu-button pattern: the trigger owns `aria-haspopup="menu"` and
* `aria-expanded`, the panel is a `role="menu"` and roving focus moves between
* `role="menuitem"` children with the arrow keys.
*
* Opening with ArrowUp focuses the last item, opening with ArrowDown or Enter
* focuses the first — the small detail that makes a menu feel native.
*/
export interface DropdownItem {
id: string
label: ReactNode
href?: string
onSelect?: () => void
icon?: ReactNode
/** Right-aligned hint, e.g. a keyboard shortcut. */
meta?: ReactNode
disabled?: boolean
tone?: 'default' | 'danger'
}
export interface DropdownGroup {
label?: string
items: DropdownItem[]
}
export interface DropdownProps {
trigger: (props: {
'aria-expanded': boolean
'aria-haspopup': 'menu'
'aria-controls': string
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
ref: React.Ref<HTMLButtonElement>
}) => ReactNode
groups: DropdownGroup[]
align?: 'start' | 'end'
label?: string
className?: string
menuClassName?: string
}
export function Dropdown({
trigger,
groups,
align = 'start',
label = 'Menu',
className,
menuClassName,
}: DropdownProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
const uid = useId()
const menuId = `${uid}-menu`
const flatItems = groups.flatMap((group) => group.items).filter((item) => !item.disabled)
const close = (focusTrigger = true) => {
setOpen(false)
if (focusTrigger) triggerRef.current?.focus()
}
useDismiss([rootRef], open, () => close(false))
const openAt = (position: 'first' | 'last') => {
setOpen(true)
requestAnimationFrame(() => {
const index = position === 'first' ? 0 : flatItems.length - 1
itemRefs.current[index]?.focus()
})
}
const onTriggerKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openAt('first')
} else if (event.key === 'ArrowUp') {
event.preventDefault()
openAt('last')
}
}
const onItemKeyDown = (event: KeyboardEvent, index: number) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
itemRefs.current[(index + 1) % flatItems.length]?.focus()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
itemRefs.current[(index - 1 + flatItems.length) % flatItems.length]?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
itemRefs.current[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
itemRefs.current[flatItems.length - 1]?.focus()
} else if (event.key === 'Tab') {
close(false)
}
}
// Index every enabled item up front. Mutating a counter while rendering
// would work today and break the moment rendering is interrupted.
const itemIndex = new Map<string, number>()
flatItems.forEach((item, index) => itemIndex.set(item.id, index))
return (
<div ref={rootRef} className={cn('relative inline-block', className)}>
{trigger({
'aria-expanded': open,
'aria-haspopup': 'menu',
'aria-controls': menuId,
onClick: () => (open ? close() : openAt('first')),
onKeyDown: onTriggerKeyDown,
ref: triggerRef,
})}
{open ? (
<div
id={menuId}
role="menu"
aria-label={label}
className={cn(
'animate-scale-in absolute z-50 mt-1.5 min-w-56 rounded-lg border border-line bg-surface-raised p-1 shadow-md',
align === 'end' ? 'right-0' : 'left-0',
menuClassName,
)}
>
{groups.map((group, groupIndex) => (
<div
key={group.label ?? `group-${groupIndex}`}
role="group"
aria-label={group.label}
className={cn(groupIndex > 0 && 'mt-1 border-t border-line-subtle pt-1')}
>
{group.label ? (
<p className="label-caps px-2.5 py-1.5 text-ink-subtle">{group.label}</p>
) : null}
{group.items.map((item) => {
const index = itemIndex.get(item.id) ?? -1
const itemClass = cn(
'flex w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left text-sm transition-colors duration-150',
item.disabled
? 'cursor-not-allowed text-ink-subtle opacity-60'
: item.tone === 'danger'
? 'text-danger hover:bg-danger-soft'
: 'text-ink hover:bg-surface-sunken',
)
if (item.href && !item.disabled) {
return (
<Link
key={item.id}
href={item.href}
role="menuitem"
tabIndex={-1}
ref={(el) => {
itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => close(false)}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</Link>
)
}
return (
<button
key={item.id}
type="button"
role="menuitem"
tabIndex={-1}
disabled={item.disabled}
ref={(el) => {
if (!item.disabled) itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => {
item.onSelect?.()
close()
}}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</button>
)
})}
</div>
))}
</div>
) : null}
</div>
)
}
hooks/use-dismiss.ts
'use client'
import { useEffect, type RefObject } from 'react'
/**
* Closes a layer on Escape or on a pointer press outside it.
*
* `pointerdown` is used rather than `click` so that dragging a text selection
* out of a popover does not dismiss it, and Escape is captured on the document
* so it works even when focus has moved into a nested portal.
*/
export function useDismiss(
refs: Array<RefObject<HTMLElement | null>>,
active: boolean,
onDismiss: () => void,
options: { escape?: boolean; outside?: boolean } = {},
): void {
const { escape = true, outside = true } = options
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (!escape) return
if (event.key === 'Escape') {
event.stopPropagation()
onDismiss()
}
}
const onPointerDown = (event: PointerEvent) => {
if (!outside) return
const target = event.target as Node | null
if (!target) return
const inside = refs.some((ref) => ref.current?.contains(target))
if (!inside) onDismiss()
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
// `refs` is a stable-length array supplied by the caller at each layer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, escape, outside, onDismiss])
}
/** Locks body scroll while a modal layer is open, without layout shift. */
export function useScrollLock(active: boolean): void {
useEffect(() => {
if (!active) return
const { body, documentElement } = document
const scrollBarWidth = window.innerWidth - documentElement.clientWidth
const previousOverflow = body.style.overflow
const previousPadding = body.style.paddingRight
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
body.style.overflow = previousOverflow
body.style.paddingRight = previousPadding
}
}, [active])
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For a set of actions on an object. If the items navigate rather than act, consider plain links — a menu adds keyboard complexity that navigation does not need.
- Opening with ArrowUp focuses the last item and ArrowDown the first — the detail that makes a menu feel native.
- Group destructive actions last, separated by a rule.
- Items that navigate render as real anchors so they can be opened in a new tab.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Grouped items
- With icons and shortcut hints
- Link items
- Disabled items
- Destructive items
- Start or end aligned
Accessibility
- Roles
- Trigger carries `aria-haspopup="menu"` and `aria-expanded`; the panel is `role="menu"` with `role="menuitem"` children.
- Roving focus
- Menu items are removed from the tab order and moved between with arrow keys, Home and End.
- Dismissal
- Escape closes and returns focus to the trigger; Tab closes and moves on.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All componentsPopover
A non-modal layer with six CSS placements — the page behind stays interactive.
intermediate3 variantsCommand menu
A modal command palette: fuzzy-ranked results, grouped output, active-descendant browsing and a keyboard-first footer.
advancedFeatured4 variantsButton
The complete action surface: nine variants, five sizes, and a loading state that preserves the control’s measured width.
starterFeatured7 variants