Dropdown Menu
Primitive

Dropdown Menu

Headless button-triggered menu primitives for actions and compact choices.

Live behavior

Dropdown Menu in motion

Interactive
Atom behavior · App-owned appearance

Interact with the specimen and inspect the behavior Atom contributes.

waiting for input

When to Use

Use DropdownMenu when a visible button opens a short set of commands or menu choices. Use ContextMenu when actions belong to a right-clicked target, Select when one value is chosen for a form, and Menubar for persistent application commands. Do not use a menu as ordinary site navigation when native links in a NavList are sufficient.

Submenus use an explicit cascade that is operable by tap/click and keyboard. Atom does not infer a drill-in presentation from screen size or pointer type. Applications that need a mobile drill-in flow should compose a separate panel, Dialog, Drawer, or grouped list with explicit depth and back controls.

Features

The behavior Atom owns before your product adds appearance.

  • Supports controlled state, modal behavior, looping, and dismissal options.
  • Opens from pointer or keyboard with real focus on the expected first/last item.
  • Includes actions, checkbox/radio choices, groups, separators, and submenus.
  • Provides typeahead, focus restoration, collision-aware positioning, and RTL submenus.
  • Registers portalled content with parent modal focus scopes.

Import

tsx
import { DropdownMenu } from "@flowstack-ui/atom";

Anatomy

tsx
<DropdownMenu.Root>
  <DropdownMenu.Trigger />
  <DropdownMenu.Portal>
  <DropdownMenu.Content>
    <DropdownMenu.Arrow />
    <DropdownMenu.Group>
      <DropdownMenu.Label />
      <DropdownMenu.Item />
      <DropdownMenu.CheckboxItem><DropdownMenu.ItemIndicator /></DropdownMenu.CheckboxItem>
      <DropdownMenu.RadioGroup>
        <DropdownMenu.RadioItem />
      </DropdownMenu.RadioGroup>
    </DropdownMenu.Group>
    <DropdownMenu.Separator />
    <DropdownMenu.Sub>
      <DropdownMenu.SubTrigger />
      <DropdownMenu.SubContent>
        <DropdownMenu.Item />
      </DropdownMenu.SubContent>
    </DropdownMenu.Sub>
  </DropdownMenu.Content>
  </DropdownMenu.Portal>
</DropdownMenu.Root>

API Reference

Root

Owns shared menu state and renders no wrapper.

PropTypeDefault
openboolean-
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void-
modalbooleantrue
closeOnSelectbooleantrue
loopbooleantrue
closeOnEscapebooleantrue

Trigger

Renders a native button by default. Click/tap, Enter, Space, and ArrowDown open with focus on the first item; ArrowUp opens with focus on the last.

PropTypeDefault
disabledbooleanfalse
asChildbooleanfalse
renderRenderProp-
ARIA attributeValues
role"button" for custom elements
aria-haspopup"menu"
aria-expandedOpen state
aria-controlsGenerated Content ID
aria-disabled"true" when disabled
Data attributeValues
[data-slot]"dropdown-menu-trigger"
[data-state]"open" | "closed"
[data-disabled]Present when disabled

Content

Renders the portalled vertical menu, positions it against Trigger, manages real item focus/typeahead, and applies reason-aware final focus. The resolved explicit, Trigger, or provider direction is preserved on the portalled Content and nested SubContent DOM.

PropTypeDefault
side"top" | "right" | "bottom" | "left""bottom"
align"start" | "center" | "end""start"
sideOffsetnumber4
loopbooleanRoot loop
ariaLabelstring-
onKeyDownCaptureKeyboardEventHandler-
asChildbooleanfalse
renderRenderProp-
ARIA attributeValues
role"menu"
aria-orientation"vertical"
aria-labelValue from ariaLabel
aria-labelledbyTrigger ID when ariaLabel is absent
Data attributeValues
[data-slot]"menu-content"
[data-state]"open" | "closed"
[data-side]Resolved side
[data-align]Resolved alignment
[data-positioned]Present after positioning

Group

Renders a semantic group for related entries.

ARIA attributeValues
role"group"
Data attributeValues
[data-slot]"menu-group"

Item

Represents one command. textValue supplies typeahead text when children are not a plain string.

PropTypeDefault
valuestringrequired
textValuestringText child or value
onSelect() => void-
disabledbooleanfalse
closeOnSelectbooleanRoot setting
asChildbooleanfalse
renderRenderProp-
ARIA attributeValues
role"menuitem"
aria-disabled"true" when disabled
Data attributeValues
[data-slot]"menu-item"
[data-highlighted]Present when highlighted
[data-disabled]Present when disabled
[data-value]Item value

CheckboxItem

Represents an independent menu choice and stays open by default.

PropTypeDefault
valuestringrequired
textValuestringText child or value
checkedboolean | "indeterminate"false
onCheckedChange(checked: boolean) => void-
disabledbooleanfalse
closeOnSelectbooleanfalse
ARIA attributeValues
role"menuitemcheckbox"
aria-checkedChecked state
aria-disabled"true" when disabled
Data attributeValues
[data-slot]"menu-checkbox-item"
[data-highlighted]Present when highlighted
[data-disabled]Present when disabled
[data-checked]Present when checked
[data-value]Item value

RadioGroup

Provides one controlled value to nested RadioItems.

PropTypeDefault
valuestring-
onValueChange(value: string) => void-
ARIA attributeValues
role"group"
Data attributeValues
[data-slot]"menu-radio-group"

RadioItem

Represents one mutually exclusive choice and stays open by default.

PropTypeDefault
valuestringrequired
textValuestringText child or value
disabledbooleanfalse
closeOnSelectbooleanfalse
ARIA attributeValues
role"menuitemradio"
aria-checkedWhether its value matches RadioGroup
aria-disabled"true" when disabled
Data attributeValues
[data-slot]"menu-radio-item"
[data-highlighted]Present when highlighted
[data-disabled]Present when disabled
[data-checked]Present when selected
[data-value]Item value

Separator

Creates a semantic horizontal boundary between entry groups.

ARIA attributeValues
role"separator"
aria-orientation"horizontal"
Data attributeValues
[data-slot]"menu-separator"

Sub

Owns controlled or uncontrolled state for one nested menu and renders no DOM.

PropTypeDefault
openboolean-
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void-

SubTrigger

Renders a menu item that opens SubContent after intentional mouse movement and a hover delay, by click, or with the direction-aware submenu key. Merely positioning a menu beneath a stationary pointer does not open the submenu.

PropTypeDefault
valuestringrequired
textValuestringText child or value
disabledbooleanfalse
ARIA attributeValues
role"menuitem"
aria-haspopup"menu"
aria-expandedSub open state
aria-disabled"true" when disabled
Data attributeValues
[data-slot]"menu-sub-trigger"
[data-state]"open" | "closed"
[data-highlighted]Present when highlighted
[data-disabled]Present when disabled
[data-value]Trigger value

SubContent

Renders a separately portalled nested menu and mirrors placement and open/close keys in RTL.

PropTypeDefault
sideOffsetnumber4
loopbooleantrue
ariaLabelstring-
ARIA attributeValues
role"menu"
aria-orientation"vertical"
aria-labelValue from ariaLabel
aria-labelledbySubTrigger ID when unlabeled
Data attributeValues
[data-slot]"menu-sub-content"
[data-state]"open" | "closed"
[data-side]Resolved side
[data-positioned]Present after positioning

The entry point also exports shared Menu context hooks for advanced custom parts. Prefer the namespaced parts for the complete behavior above.

Portal, Arrow, Label, and ItemIndicator use the shared Menu contract. All retained DOM parts accept refs, native props, asChild, and render. Content and SubContent expose the --atom-menu-available-*, --atom-menu-trigger-*, and --atom-menu-transform-origin variables.

Examples

Project Actions

tsx
import { DropdownMenu } from "@flowstack-ui/atom";

export function ProjectActions() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
      <DropdownMenu.Content ariaLabel="Project actions">
        <DropdownMenu.Item value="duplicate" onSelect={() => console.log("Duplicate")}>
          Duplicate
        </DropdownMenu.Item>
        <DropdownMenu.Item value="archive" onSelect={() => console.log("Archive")}>
          Archive
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Persistent View Options

tsx
import { useState } from "react";
import { DropdownMenu } from "@flowstack-ui/atom";

export function ViewOptions() {
  const [grid, setGrid] = useState(true);
  return (
    <DropdownMenu.Root closeOnSelect={false}>
      <DropdownMenu.Trigger>View</DropdownMenu.Trigger>
      <DropdownMenu.Content ariaLabel="View options">
        <DropdownMenu.CheckboxItem value="grid" checked={grid} onCheckedChange={setGrid}>
          Show grid
        </DropdownMenu.CheckboxItem>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Accessibility

DropdownMenu follows the WAI-ARIA Menu pattern. Trigger provides the menu popup relationship. Content moves real focus among all entries; disabled entries are announced but cannot activate. Use visible Trigger text that describes the menu.

KeyDescription
Enter / Space / ArrowDownOpens and focuses the first entry.
ArrowUpOpens and focuses the last entry.
ArrowDown / ArrowUpMoves through entries while open.
Home / EndMoves to the first or last entry.
Printable characterMoves by typeahead label.
ArrowRightOpens a submenu in LTR; closes it in RTL.
ArrowLeftCloses a submenu in LTR; opens it in RTL.
Enter / SpaceActivates the highlighted entry.
EscapeCloses the topmost submenu or menu.
Tab / Shift+TabCloses and moves after/before Trigger in document order.

Changelog

Unreleased

0.24.0

  • Added public Agent Knowledge for component selection, required composition, recurring mistakes, and validation.

0.20.6

  • Inherited movement-gated submenu hover intent so opening a parent DropdownMenu cannot also open a submenu that appears beneath a stationary pointer.

0.20.5

  • Inherited resolved direction on portalled Content and SubContent so logical row anatomy and submenu chevrons mirror with the trigger.
  • Inherited block-axis submenu collision fallbacks and narrow-viewport containment when neither inline side fits.

0.20.3

  • Inherited document-only overflow locking so sticky application chrome remains anchored while a modal DropdownMenu is open.

0.12.0

  • Inherited real item focus, disabled-item navigation, owner-aware Tab exit, modal isolation, deferred touch dismissal, and corrected submenu focus.
  • Added Portal, Arrow, Label, ItemIndicator, mixed checkbox state, complete DOM composition, and positioning variables through the shared Menu engine.
  • Click/tap opening now focuses the first item; ArrowUp still opens at the last.

0.3.4

  • Inherited corrected modal Menu scroll-lock compensation for documents that preserve their scrollbar gutter.

0.3.1

  • Inherited reliable Menu exit-presence cleanup for closed DropdownMenu and submenu Content under global motion CSS.

0.2.0

  • Inherited fixed Menu part data-slot pass-through so DropdownMenu Content, Group, Separator, CheckboxItem, RadioGroup, RadioItem, SubTrigger, and SubContent can be overridden consistently.
  • Inherited fixed submenu keyboard behavior under Direction.Provider dir="rtl" so ArrowLeft opens submenus, ArrowRight closes submenus, and submenu placement mirrors to the left side.
  • Inherited the shared Menu typeahead behavior so a single-character search cycles from the current matching item while multi-character buffers still match exact prefixes.
  • Fixed pointer-open behavior so clicking the trigger opens without pre-highlighting the first item; keyboard opening still seeds first/last highlight.
  • Fixed pointer reopen behavior so closing presence frames cannot leave a stale first-item highlight for the next trigger click.
  • Inherited fixed submenu Escape handling so nested DropdownMenu submenus close before the root dropdown or parent Dialog/Modal layer.
  • Added shared dismissable layer Escape handling so DropdownMenu closes before parent overlays when nested inside Dialog, Drawer, Modal, or Popover.
  • Registered shared Menu content with parent modal focus scopes so DropdownMenu can remain a valid focus target inside Dialog, Drawer, and other modal primitives.

0.1.0

  • Initial Atom release.