Skip to content

Dropdown menu

The ARIA menu-button pattern with grouped items, roving focus, shortcuts and destructive styling.

NavigationintermediateFeaturedmenuactionscontextkeyboard

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.