# Dropdown Menu

A menu of actions or options that opens from a button.

Dropdown Menu is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/dropdown-menu

**React**

**Example**

```tsx
import {
  ChatCircleIcon,
  CreditCardIcon,
  EnvelopeIcon,
  GearIcon,
  SignOutIcon,
  TrashIcon,
  UserIcon,
  UserPlusIcon,
} from "@phosphor-icons/react"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export default function DropdownMenuDemo() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="outline" />}>
        Open menu
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuGroup>
          <DropdownMenuLabel>My account</DropdownMenuLabel>
          <DropdownMenuItem>
            <UserIcon />
            Profile
            <DropdownMenuShortcut mod>⇧P</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            <CreditCardIcon />
            Billing
            <DropdownMenuShortcut mod>B</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            <GearIcon />
            Settings
            <DropdownMenuShortcut mod>,</DropdownMenuShortcut>
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuSub>
          <DropdownMenuSubTrigger>
            <UserPlusIcon />
            Invite users
          </DropdownMenuSubTrigger>
          <DropdownMenuSubContent>
            <DropdownMenuItem>
              <EnvelopeIcon />
              Email
            </DropdownMenuItem>
            <DropdownMenuItem>
              <ChatCircleIcon />
              Message
            </DropdownMenuItem>
          </DropdownMenuSubContent>
        </DropdownMenuSub>
        <DropdownMenuItem disabled>API access</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">
          <TrashIcon />
          Delete account
        </DropdownMenuItem>
        <DropdownMenuItem>
          <SignOutIcon />
          Log out
          <DropdownMenuShortcut mod>⇧Q</DropdownMenuShortcut>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
```

Dropdown menu wraps the menu primitive of the chosen base. The `DropdownMenu` root manages the open state and `DropdownMenuTrigger` opens it. `DropdownMenuContent` renders the menu in a portal, positioned below the trigger. Inside it, use `DropdownMenuItem` for actions, `DropdownMenuCheckboxItem` for toggles, and `DropdownMenuRadioGroup` with `DropdownMenuRadioItem` for a single choice. Organize items with `DropdownMenuGroup`, `DropdownMenuLabel` and `DropdownMenuSeparator`, and nest a submenu with `DropdownMenuSub`, `DropdownMenuSubTrigger` and `DropdownMenuSubContent`. `DropdownMenuShortcut` displays a keyboard shortcut at the end of an item.

Use a dropdown menu for actions and settings behind a visible button. For actions on an object opened with a right-click, use a context menu; to choose a value for a form field, use a select.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/dropdown-menu
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add dropdown-menu
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react
```

2. Add the pieces it builds on: `use-platform`.

3. Copy the source into `components/ui/dropdown-menu.tsx`:

```tsx title="components/ui/dropdown-menu.tsx"
"use client"

import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
import type * as React from "react"
import { useModifierKey } from "@/hooks/use-platform"
import { cn } from "@/lib/utils"

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(
            "cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
            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 ? "" : undefined}
      className={cn(
        "px-3 py-2.5 text-xs font-semibold text-muted-foreground data-inset:ps-9.5",
        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 ? "" : undefined}
      data-variant={variant}
      className={cn(
        "group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-md px-3 py-2 text-base outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive data-disabled:opacity-50",
        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 ? "" : undefined}
      className={cn(
        "flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-base outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <CaretRightIcon className="rtl:rotate-180 ms-auto" />
    </MenuPrimitive.SubmenuTrigger>
  )
}

function DropdownMenuSubContent({
  align = "start",
  alignOffset = -3,
  side = "inline-end",
  sideOffset = 0,
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      data-slot="dropdown-menu-sub-content"
      className={cn(
        "cn-menu-target cn-menu-translucent w-auto min-w-36 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
        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 ? "" : undefined}
      className={cn(
        "relative flex cursor-default items-center gap-2.5 rounded-md py-2 pe-8 ps-3 text-base outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-9.5 data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:opacity-50",
        className
      )}
      checked={checked}
      {...props}
    >
      <span
        className="pointer-events-none absolute inset-e-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 ? "" : undefined}
      className={cn(
        "relative flex cursor-default items-center gap-2.5 rounded-md py-2 pe-8 ps-3 text-base outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-9.5 data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      <span
        className="pointer-events-none absolute inset-e-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.5 my-1.5 h-px bg-border/50", className)}
      {...props}
    />
  )
}

function DropdownMenuShortcut({
  className,
  mod,
  children,
  ...props
}: React.ComponentProps<"span"> & { mod?: boolean }) {
  const modifier = useModifierKey(children != null)

  return (
    <span
      data-slot="dropdown-menu-shortcut"
      className={cn(
        "max-md:hidden ms-auto text-xs text-muted-foreground group-focus/dropdown-menu-item:text-foreground",
        className
      )}
      {...props}
    >
      {mod && modifier}
      {children}
    </span>
  )
}

export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut } from "@/components/ui/dropdown-menu"
```

```tsx
<DropdownMenu>
  <DropdownMenuTrigger render={<Button variant="outline" />}>
    Open menu
  </DropdownMenuTrigger>
  <DropdownMenuContent className="w-56">
    <DropdownMenuGroup>
      <DropdownMenuLabel>My account</DropdownMenuLabel>
      <DropdownMenuItem>
        <UserIcon />
        Profile
        <DropdownMenuShortcut mod>⇧P</DropdownMenuShortcut>
      </DropdownMenuItem>
      <DropdownMenuItem>
        <CreditCardIcon />
        Billing
        <DropdownMenuShortcut mod>B</DropdownMenuShortcut>
      </DropdownMenuItem>
      <DropdownMenuItem>
        <GearIcon />
        Settings
        <DropdownMenuShortcut mod>,</DropdownMenuShortcut>
      </DropdownMenuItem>
    </DropdownMenuGroup>
    <DropdownMenuSeparator />
    <DropdownMenuSub>
      <DropdownMenuSubTrigger>
        <UserPlusIcon />
        Invite users
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent>
        <DropdownMenuItem>
          <EnvelopeIcon />
          Email
        </DropdownMenuItem>
        <DropdownMenuItem>
          <ChatCircleIcon />
          Message
        </DropdownMenuItem>
      </DropdownMenuSubContent>
    </DropdownMenuSub>
    <DropdownMenuItem disabled>API access</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem variant="destructive">
      <TrashIcon />
      Delete account
    </DropdownMenuItem>
    <DropdownMenuItem>
      <SignOutIcon />
      Log out
      <DropdownMenuShortcut mod>⇧Q</DropdownMenuShortcut>
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>
```

The menu is uncontrolled by default; set `defaultOpen` to start open, or control it with `open` and `onOpenChange`. Render the trigger as another component, such as a `Button`, with the `render` prop.

Selecting a regular item closes the menu.

Checkbox and radio items keep the menu open so several choices can be made in a row.

Checkbox items are uncontrolled with `defaultChecked`, or controlled with `checked` and `onCheckedChange`. Radio groups take `defaultValue`, or `value` and `onValueChange`, and each radio item needs a `value`.

Set `inset` on items and labels without an icon to align their text with items that have one, and set `variant="destructive"` on items that delete or remove something.

## Examples

### Checkboxes

Controlled checkbox items that toggle interface elements, including a disabled item.

**Example**

```tsx
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

function CheckboxesExample(props: React.ComponentProps<typeof DropdownMenu>) {
  const [statusBar, setStatusBar] = React.useState(true)
  const [activityBar, setActivityBar] = React.useState(false)
  const [panel, setPanel] = React.useState(false)

  return (
    <DropdownMenu {...props}>
      <DropdownMenuTrigger render={<Button variant="outline" />}>
        View
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuGroup>
          <DropdownMenuLabel>Appearance</DropdownMenuLabel>
          <DropdownMenuCheckboxItem
            checked={statusBar}
            onCheckedChange={setStatusBar}
          >
            Status bar
          </DropdownMenuCheckboxItem>
          <DropdownMenuCheckboxItem
            checked={activityBar}
            onCheckedChange={setActivityBar}
          >
            Activity bar
          </DropdownMenuCheckboxItem>
          <DropdownMenuCheckboxItem
            checked={panel}
            onCheckedChange={setPanel}
            disabled
          >
            Panel
          </DropdownMenuCheckboxItem>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export default function DropdownMenuCheckboxes() {
  return <CheckboxesExample />
}
```

### Radio group

A controlled radio group for choosing a single panel position.

**Example**

```tsx
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

function RadioGroupExample(props: React.ComponentProps<typeof DropdownMenu>) {
  const [position, setPosition] = React.useState("bottom")

  return (
    <DropdownMenu {...props}>
      <DropdownMenuTrigger render={<Button variant="outline" />}>
        Panel position
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuGroup>
          <DropdownMenuLabel>Position</DropdownMenuLabel>
          <DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
            <DropdownMenuRadioItem value="top">Top</DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="bottom">Bottom</DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="right">Right</DropdownMenuRadioItem>
          </DropdownMenuRadioGroup>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export default function DropdownMenuRadioGroupExample() {
  return <RadioGroupExample />
}
```

### Inset

A labelled group of inset items without icons, each with a keyboard shortcut.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export default function DropdownMenuInset() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="outline" />}>
        Edit
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuGroup>
          <DropdownMenuLabel inset>Clipboard</DropdownMenuLabel>
          <DropdownMenuItem inset>
            Cut
            <DropdownMenuShortcut mod>X</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem inset>
            Copy
            <DropdownMenuShortcut mod>C</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem inset>
            Paste
            <DropdownMenuShortcut mod>V</DropdownMenuShortcut>
          </DropdownMenuItem>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
```

## API Reference

- `DropdownMenu`
- `DropdownMenuTrigger`
- `DropdownMenuContent`
  - `DropdownMenuGroup`
    - `DropdownMenuLabel`
    - `DropdownMenuItem`
      - `DropdownMenuShortcut`
  - `DropdownMenuSeparator`
  - `DropdownMenuSub`
    - `DropdownMenuSubTrigger`
    - `DropdownMenuSubContent`
      - `DropdownMenuItem`
  - `DropdownMenuItem`
    - `DropdownMenuShortcut`

### DropdownMenu

The root that manages the menu's open state.

Also accepts the props of the primitive part it wraps.

### DropdownMenuPortal

Renders its children into a portal outside the current DOM hierarchy.

Also accepts the props of the primitive part it wraps.

### DropdownMenuTrigger

The button that opens the menu.

Also accepts the props of the primitive part it wraps.

### DropdownMenuContent

The menu panel, rendered in a portal and positioned relative to the trigger.

Also accepts the props of the primitive parts it wraps.

### DropdownMenuGroup

Groups related items.

Also accepts the props of the primitive part it wraps.

### DropdownMenuLabel

A heading for a group of items.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds leading padding so the label aligns with items that have an icon or indicator. |

Also accepts the props of the primitive part it wraps.

### DropdownMenuItem

An action in the menu.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds leading padding so the item aligns with items that have an icon or indicator. |
| `variant` | `"default" \| "destructive"` | `"default"` | `destructive` colors the item and its icon with the `destructive` color. |

Also accepts the props of the primitive part it wraps.

### DropdownMenuSub

The root of a nested submenu.

Also accepts the props of the primitive part it wraps.

### DropdownMenuSubTrigger

The item that opens a submenu, shown with a trailing caret.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds leading padding so the trigger aligns with items that have an icon or indicator. |

Also accepts the props of the primitive part it wraps.

### DropdownMenuSubContent

The panel of a submenu, opened beside its trigger.

Also accepts the props of `React.ComponentProps<typeof DropdownMenuContent>`.

### DropdownMenuCheckboxItem

An item that toggles a setting on or off and shows a check mark when on.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds leading padding so the item aligns with other inset items. |

Also accepts the props of the primitive part it wraps.

### DropdownMenuRadioGroup

Groups radio items so that only one can be selected.

Also accepts the props of the primitive part it wraps.

### DropdownMenuRadioItem

An item in a radio group that shows a check mark when selected.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds leading padding so the item aligns with other inset items. |

Also accepts the props of the primitive part it wraps.

### DropdownMenuSeparator

A divider between groups of items.

Also accepts the props of the primitive part it wraps.

### DropdownMenuShortcut

Displays a keyboard shortcut at the end of an item.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mod` | `boolean` | - | Prefixes the shortcut with the platform modifier key, `⌘` on Apple platforms and `Ctrl+` elsewhere. |

Also accepts the props of `React.ComponentProps<"span">`.

## Accessibility

- The trigger sets `aria-expanded`. The menu has the `menu` role; items have the `menuitem`, `menuitemcheckbox` or `menuitemradio` role, and checkbox and radio items set `aria-checked`.
- Groups have the `group` role and take their name from `DropdownMenuLabel`.
- Opening the menu from the keyboard focuses the first item. Arrow keys move between items, and disabled items set `aria-disabled`.
- On a submenu trigger, the Right Arrow opens the submenu and moves focus into it, and the Left Arrow returns to the trigger. The arrow directions follow the reading direction.
- Escape closes the menu and returns focus to the trigger.
- `DropdownMenuShortcut` only displays a shortcut; it does not register one. It is hidden on small screens.

## Notes

- Styling hooks: `data-slot` values such as `dropdown-menu-content`, `dropdown-menu-item`, `dropdown-menu-checkbox-item`, `dropdown-menu-checkbox-item-indicator`, `dropdown-menu-radio-item`, `dropdown-menu-radio-item-indicator`, `dropdown-menu-sub-trigger`, `dropdown-menu-label`, `dropdown-menu-separator` and `dropdown-menu-shortcut`.
- Items set `data-inset` and `data-variant`; disabled items get `data-disabled`, and an open submenu trigger gets `data-popup-open`.
- By default the menu opens below the trigger, aligned to `start`, and matches the trigger's width with a minimum of `12rem`. Override the width with `className`.
- Uses the `popover`, `accent`, `destructive` and `border` colors.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-dropdown-menu--open-by-default.light.webp)

```tsx
import { ChatCircleIcon, CreditCardIcon, EnvelopeIcon, GearIcon, SignOutIcon, UserIcon, UserPlusIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function OpenButton({ label = "Open menu" }: { label?: string }) {
  return (
    <DropdownMenuTrigger render={<Button variant="outline" />}>
      <Text>{label}</Text>
    </DropdownMenuTrigger>
  )
}

export function DropdownMenuOpenByDefaultExample() {
  return (
    <View className="items-start">
      <DropdownMenu defaultOpen>
        <OpenButton />
        <DropdownMenuContent className="w-56" align="start">
          <DropdownMenuLabel>My account</DropdownMenuLabel>
          <DropdownMenuGroup>
            <DropdownMenuItem>
              <Icon as={UserIcon} className="size-4" />
              <Text>Profile</Text>
            </DropdownMenuItem>
            <DropdownMenuItem>
              <Icon as={CreditCardIcon} className="size-4" />
              <Text>Billing</Text>
            </DropdownMenuItem>
            <DropdownMenuItem>
              <Icon as={GearIcon} className="size-4" />
              <Text>Settings</Text>
            </DropdownMenuItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>
              <View className="flex-row items-center gap-2">
                <Icon as={UserPlusIcon} className="size-4" />
                <Text>Invite users</Text>
              </View>
            </DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuItem>
                <Icon as={EnvelopeIcon} className="size-4" />
                <Text>Email</Text>
              </DropdownMenuItem>
              <DropdownMenuItem>
                <Icon as={ChatCircleIcon} className="size-4" />
                <Text>Message</Text>
              </DropdownMenuItem>
            </DropdownMenuSubContent>
          </DropdownMenuSub>
          <DropdownMenuSeparator />
          <DropdownMenuItem variant="destructive">
            <Icon as={SignOutIcon} className="size-4" />
            <Text>Log out</Text>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct DropdownMenuOpenByDefaultExample: View {
  var body: some View {
    DropdownMenuAccount(open: true)
  }
}

struct DropdownMenuAccount: View {
  @State private var open: Bool

  init(open: Bool = false) {
    _open = State(initialValue: open)
  }

  var body: some View {
    DropdownMenu(isPresented: $open) {
      DropdownMenuLabel("My account")
      DropdownMenuGroup {
        DropdownMenuItem("Profile", icon: Ph.user.regular) {}
        DropdownMenuItem("Billing", icon: Ph.creditCard.regular) {}
        DropdownMenuItem("Settings", icon: Ph.gear.regular) {}
      }
      DropdownMenuSeparator()
      DropdownMenuSub("Invite users", icon: Ph.userPlus.regular) {
        DropdownMenuItem("Email", icon: Ph.envelope.regular) {}
        DropdownMenuItem("Message", icon: Ph.chatCircle.regular) {}
      }
      DropdownMenuSeparator()
      DropdownMenuItem("Log out", icon: Ph.signOut.regular, variant: .destructive) {}
    } label: {
      Text("Open menu")
    }
    .buttonStyle(.variant(.outline))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.CreditCard
import com.adamglin.phosphoricons.regular.Envelope
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.SignOut
import com.adamglin.phosphoricons.regular.User
import com.adamglin.phosphoricons.regular.UserPlus
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.DropdownMenu
import com.esuiss.ui.components.DropdownMenuGroup
import com.esuiss.ui.components.DropdownMenuItem
import com.esuiss.ui.components.DropdownMenuLabel
import com.esuiss.ui.components.DropdownMenuSeparator
import com.esuiss.ui.components.DropdownMenuSub
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.PopupAlign

@Composable
fun DropdownMenuOpenByDefaultExample() {
    DropdownMenuAccount(defaultOpen = true)
}

@Composable
fun DropdownMenuDemo(
    label: String,
    defaultOpen: Boolean = false,
    content: @Composable () -> Unit,
) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        DropdownMenu(
            expanded = open,
            onExpandedChange = { open = it },
            trigger = { Button(label, onClick = { open = !open }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(224.dp),
            align = PopupAlign.Start,
        ) {
            content()
        }
    }
}

@Composable
fun DropdownMenuAccount(defaultOpen: Boolean) {
    DropdownMenuDemo("Open menu", defaultOpen) {
        DropdownMenuLabel("My account")
        DropdownMenuGroup {
            DropdownMenuItem("Profile", onClick = {}, icon = PhosphorIcons.Regular.User)
            DropdownMenuItem("Billing", onClick = {}, icon = PhosphorIcons.Regular.CreditCard)
            DropdownMenuItem("Settings", onClick = {}, icon = PhosphorIcons.Regular.Gear)
        }
        DropdownMenuSeparator()
        DropdownMenuSub("Invite users", icon = PhosphorIcons.Regular.UserPlus) {
            DropdownMenuItem("Email", onClick = {}, icon = PhosphorIcons.Regular.Envelope)
            DropdownMenuItem("Message", onClick = {}, icon = PhosphorIcons.Regular.ChatCircle)
        }
        DropdownMenuSeparator()
        DropdownMenuItem("Log out", onClick = {}, icon = PhosphorIcons.Regular.SignOut, variant = MenuItemVariant.Destructive)
    }
}
```

`DropdownMenu` opens `DropdownMenuContent` next to a `DropdownMenuTrigger` when it is pressed. The content renders through a portal (inside a full-window overlay on iOS) as a raised, rounded card positioned below the trigger by default.

Items can be plain actions, checkbox items, radio items in a group, or a submenu. `DropdownMenuSubContent` expands in place under its trigger, and the caret turns to point down while it is open. For a menu that opens on long press at the touch point, use Context Menu.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/dropdown-menu
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add dropdown-menu
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add dropdown-menu --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add dropdown-menu --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/menu.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dropdown-menu.tsx`:

```tsx title="uniwind/components/ui/dropdown-menu.tsx"
import { cn } from "cn"
import { CaretRightIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as MenuPrimitive from "@/components/primitives/menu"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { TextClassContext } from "@/components/ui/text"

const DropdownMenu = MenuPrimitive.Root

const DropdownMenuPortal = MenuPrimitive.Portal

const DropdownMenuTrigger = MenuPrimitive.Trigger

const DropdownMenuGroup = MenuPrimitive.Group

const DropdownMenuSub = MenuPrimitive.SubmenuRoot

const DropdownMenuRadioGroup = MenuPrimitive.RadioGroup

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

type PositionerProps = React.ComponentProps<typeof MenuPrimitive.Positioner>

function DropdownMenuContent({
  className,
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
  Pick<PositionerProps, "align" | "alignOffset" | "side" | "sideOffset"> & {
    portalHost?: string
  }) {
  return (
    <MenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <MenuPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
        >
          <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
            <TextClassContext.Provider value="text-base text-foreground">
              <MenuPrimitive.Popup
                className={(state) =>
                  cn(
                    "min-w-48 rounded-xl border border-foreground/5 bg-popover p-1.5 shadow-lg dark:border-foreground/10",
                    resolveClassName(className, state)
                  )
                }
                {...props}
              />
            </TextClassContext.Provider>
          </AnimatedView>
        </MenuPrimitive.Positioner>
      </FullWindowOverlay>
    </MenuPrimitive.Portal>
  )
}

function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  iconClassName,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
  children?: React.ReactNode
  iconClassName?: string
  inset?: boolean
}) {
  const { open } = MenuPrimitive.useMenuRootContext().floating
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.SubmenuTrigger
        className={(state) =>
          cn(
            "group flex-row items-center gap-2 rounded-md px-3 py-2",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            state.open && !state.highlighted && "bg-accent",
            inset && "pl-9.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {children}
        <View className={cn("ml-auto shrink-0", open && "rotate-90")}>
          <Icon as={CaretRightIcon} className={cn("size-4", iconClassName)} />
        </View>
      </MenuPrimitive.SubmenuTrigger>
    </TextClassContext.Provider>
  )
}

function DropdownMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup>) {
  return (
    <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
      <MenuPrimitive.Popup
        className={(state) =>
          cn(
            "min-w-36 rounded-xl border border-foreground/5 bg-popover p-1.5 shadow-lg dark:border-foreground/10",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </AnimatedView>
  )
}

function DropdownMenuItem({
  className,
  inset,
  variant = "default",
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
  inset?: boolean
  variant?: "default" | "destructive"
}) {
  const destructive = variant === "destructive"
  return (
    <TextClassContext.Provider
      value={cn(
        "select-none text-base text-foreground",
        destructive && "text-destructive"
      )}
    >
      <MenuPrimitive.Item
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md px-3 py-2",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-9.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </TextClassContext.Provider>
  )
}

function DropdownMenuCheckboxItem({
  className,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.CheckboxItem
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-9.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        <View className="absolute right-2 size-4 items-center justify-center">
          <MenuPrimitive.CheckboxItemIndicator>
            <Icon as={CheckIcon} className="size-4" />
          </MenuPrimitive.CheckboxItemIndicator>
        </View>
        {children}
      </MenuPrimitive.CheckboxItem>
    </TextClassContext.Provider>
  )
}

function DropdownMenuRadioItem({
  className,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.RadioItem
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-9.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        <View className="absolute right-2 size-4 items-center justify-center">
          <MenuPrimitive.RadioItemIndicator>
            <Icon as={CheckIcon} className="size-4" />
          </MenuPrimitive.RadioItemIndicator>
        </View>
        {children}
      </MenuPrimitive.RadioItem>
    </TextClassContext.Provider>
  )
}

function DropdownMenuLabel({
  className,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.GroupLabel
      className={(state) =>
        cn(
          "px-3 py-2.5 text-xs font-semibold text-muted-foreground",
          inset && "pl-9.5",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
  return (
    <MenuPrimitive.Separator
      className={(state) =>
        cn(
          "-mx-1.5 my-1.5 h-px bg-border/50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/menu.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dropdown-menu.tsx`:

```tsx title="nativewind/components/ui/dropdown-menu.tsx"
import { cn } from "cn"
import { CaretRightIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as MenuPrimitive from "@/components/primitives/menu"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { TextClassContext } from "@/components/ui/text"

const DropdownMenu = MenuPrimitive.Root

const DropdownMenuPortal = MenuPrimitive.Portal

const DropdownMenuTrigger = MenuPrimitive.Trigger

const DropdownMenuGroup = MenuPrimitive.Group

const DropdownMenuSub = MenuPrimitive.SubmenuRoot

const DropdownMenuRadioGroup = MenuPrimitive.RadioGroup

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

type PositionerProps = React.ComponentProps<typeof MenuPrimitive.Positioner>

function DropdownMenuContent({
  className,
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
  Pick<PositionerProps, "align" | "alignOffset" | "side" | "sideOffset"> & {
    portalHost?: string
  }) {
  return (
    <MenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <MenuPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
        >
          <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
            <TextClassContext.Provider value="text-base text-foreground">
              <MenuPrimitive.Popup
                className={(state) =>
                  cn(
                    "min-w-48 rounded-xl border border-foreground-5 bg-popover p-1.5 shadow-lg dark:border-foreground-10",
                    resolveClassName(className, state)
                  )
                }
                {...props}
              />
            </TextClassContext.Provider>
          </AnimatedView>
        </MenuPrimitive.Positioner>
      </FullWindowOverlay>
    </MenuPrimitive.Portal>
  )
}

function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  iconClassName,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
  children?: React.ReactNode
  iconClassName?: string
  inset?: boolean
}) {
  const { open } = MenuPrimitive.useMenuRootContext().floating
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.SubmenuTrigger
        className={(state) =>
          cn(
            "group flex-row items-center gap-2 rounded-md px-3 py-2",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            state.open && !state.highlighted && "bg-accent",
            inset && "pl-[2.375rem]",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {children}
        <View className={cn("ml-auto shrink-0", open && "rotate-90")}>
          <Icon as={CaretRightIcon} className={cn("size-4", iconClassName)} />
        </View>
      </MenuPrimitive.SubmenuTrigger>
    </TextClassContext.Provider>
  )
}

function DropdownMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup>) {
  return (
    <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
      <MenuPrimitive.Popup
        className={(state) =>
          cn(
            "min-w-36 rounded-xl border border-foreground-5 bg-popover p-1.5 shadow-lg dark:border-foreground-10",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </AnimatedView>
  )
}

function DropdownMenuItem({
  className,
  inset,
  variant = "default",
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
  inset?: boolean
  variant?: "default" | "destructive"
}) {
  const destructive = variant === "destructive"
  return (
    <TextClassContext.Provider
      value={cn(
        "select-none text-base text-foreground",
        destructive && "text-destructive"
      )}
    >
      <MenuPrimitive.Item
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md px-3 py-2",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-[2.375rem]",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </TextClassContext.Provider>
  )
}

function DropdownMenuCheckboxItem({
  className,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.CheckboxItem
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-[2.375rem]",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        <View className="absolute right-2 size-4 items-center justify-center">
          <MenuPrimitive.CheckboxItemIndicator>
            <Icon as={CheckIcon} className="size-4" />
          </MenuPrimitive.CheckboxItemIndicator>
        </View>
        {children}
      </MenuPrimitive.CheckboxItem>
    </TextClassContext.Provider>
  )
}

function DropdownMenuRadioItem({
  className,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <MenuPrimitive.RadioItem
        className={(state) =>
          cn(
            "group relative flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            inset && "pl-[2.375rem]",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        <View className="absolute right-2 size-4 items-center justify-center">
          <MenuPrimitive.RadioItemIndicator>
            <Icon as={CheckIcon} className="size-4" />
          </MenuPrimitive.RadioItemIndicator>
        </View>
        {children}
      </MenuPrimitive.RadioItem>
    </TextClassContext.Provider>
  )
}

function DropdownMenuLabel({
  className,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.GroupLabel
      className={(state) =>
        cn(
          "px-3 py-2.5 text-xs font-semibold text-muted-foreground",
          inset && "pl-[2.375rem]",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
  return (
    <MenuPrimitive.Separator
      className={(state) =>
        cn(
          "-mx-1.5 my-1.5 h-px bg-border-50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/menu.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dropdown-menu.tsx`:

```tsx title="stylesheet/components/ui/dropdown-menu.tsx"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import { CaretRightIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewStyle,
} from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as MenuPrimitive from "@/components/primitives/menu"
import { resolveStyle } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

const DropdownMenu = MenuPrimitive.Root

const DropdownMenuPortal = MenuPrimitive.Portal

const DropdownMenuTrigger = MenuPrimitive.Trigger

const DropdownMenuGroup = MenuPrimitive.Group

const DropdownMenuSub = MenuPrimitive.SubmenuRoot

const DropdownMenuRadioGroup = MenuPrimitive.RadioGroup

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const styles = StyleSheet.create({
  itemText: { ...fontSize.base, userSelect: "none" },
  subTrigger: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    borderRadius: radius.md,
    paddingHorizontal: 12,
    paddingVertical: 8,
  },
  subTriggerIcon: { marginLeft: "auto", flexShrink: 0 },
  subTriggerIconOpen: { transform: [{ rotate: "90deg" }] },
  subContent: {
    minWidth: 144,
    borderRadius: radius.xl,
    borderWidth: 1,
    padding: 6,
    ...shadow.lg,
  },
  content: {
    minWidth: 192,
    borderRadius: radius.xl,
    borderWidth: 1,
    padding: 6,
    ...shadow.lg,
  },
  item: {
    position: "relative",
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: radius.md,
    paddingHorizontal: 12,
    paddingVertical: 8,
  },
  checkItem: {
    position: "relative",
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: radius.md,
    paddingVertical: 8,
    paddingRight: 32,
    paddingLeft: 12,
  },
  indicator: {
    position: "absolute",
    right: 8,
    width: 16,
    height: 16,
    alignItems: "center",
    justifyContent: "center",
  },
  label: {
    paddingHorizontal: 12,
    paddingVertical: 10,
    ...fontSize.xs,
    fontWeight: "600",
  },
  inset: { paddingLeft: 38 },
  separator: {
    marginHorizontal: -6,
    marginVertical: 6,
    height: 1,
    opacity: 0.5,
  },
})

function usePopupColors() {
  const { colors, isDark } = useTheme()
  return {
    borderColor: `${colors.foreground}${isDark ? "1a" : "0d"}`,
    backgroundColor: colors.popover,
  }
}

type PositionerProps = React.ComponentProps<typeof MenuPrimitive.Positioner>

function DropdownMenuContent({
  style,
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
  Pick<PositionerProps, "align" | "alignOffset" | "side" | "sideOffset"> & {
    portalHost?: string
  }) {
  const { colors } = useTheme()
  const popupColors = usePopupColors()
  return (
    <MenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <MenuPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
        >
          <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
            <TextStyleContext.Provider
              value={{ ...fontSize.base, color: colors.foreground }}
            >
              <MenuPrimitive.Popup
                style={(state) => [
                  styles.content,
                  popupColors,
                  resolveStyle(style, state) as StyleProp<ViewStyle>,
                ]}
                {...props}
              />
            </TextStyleContext.Provider>
          </AnimatedView>
        </MenuPrimitive.Positioner>
      </FullWindowOverlay>
    </MenuPrimitive.Portal>
  )
}

function DropdownMenuSubTrigger({
  style,
  inset,
  children,
  iconStyle,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
  children?: React.ReactNode
  iconStyle?: PhosphorIconProps["style"]
  inset?: boolean
}) {
  const { open } = MenuPrimitive.useMenuRootContext().floating
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{
        ...styles.itemText,
        color: colors.foreground,
      }}
    >
      <MenuPrimitive.SubmenuTrigger
        style={(state) => [
          styles.subTrigger,
          state.open && { backgroundColor: colors.accent },
          state.highlighted && { opacity: 0.8 },
          props.disabled && { opacity: 0.5 },
          inset && styles.inset,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      >
        {children}
        <View
          style={[styles.subTriggerIcon, open && styles.subTriggerIconOpen]}
        >
          <Icon as={CaretRightIcon} size={16} style={iconStyle} />
        </View>
      </MenuPrimitive.SubmenuTrigger>
    </TextStyleContext.Provider>
  )
}

function DropdownMenuSubContent({
  style,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Popup>) {
  const popupColors = usePopupColors()
  return (
    <AnimatedView entering={FadeIn.reduceMotion(ReduceMotion.System)}>
      <MenuPrimitive.Popup
        style={(state) => [
          styles.subContent,
          popupColors,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      />
    </AnimatedView>
  )
}

function DropdownMenuItem({
  style,
  inset,
  variant = "default",
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
  inset?: boolean
  variant?: "default" | "destructive"
}) {
  const { colors } = useTheme()
  const destructive = variant === "destructive"
  const color = destructive ? colors.destructive : colors.foreground
  return (
    <TextStyleContext.Provider
      value={{
        ...styles.itemText,
        color,
      }}
    >
      <MenuPrimitive.Item
        style={(state) => [
          styles.item,
          state.highlighted && { opacity: 0.8 },
          props.disabled && { opacity: 0.5 },
          inset && styles.inset,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      />
    </TextStyleContext.Provider>
  )
}

function DropdownMenuCheckboxItem({
  style,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{
        ...styles.itemText,
        color: colors.foreground,
      }}
    >
      <MenuPrimitive.CheckboxItem
        style={(state) => [
          styles.checkItem,
          state.highlighted && { opacity: 0.8 },
          props.disabled && { opacity: 0.5 },
          inset && styles.inset,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      >
        <View style={styles.indicator}>
          <MenuPrimitive.CheckboxItemIndicator>
            <Icon as={CheckIcon} size={16} />
          </MenuPrimitive.CheckboxItemIndicator>
        </View>
        {children}
      </MenuPrimitive.CheckboxItem>
    </TextStyleContext.Provider>
  )
}

function DropdownMenuRadioItem({
  style,
  children,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
  children?: React.ReactNode
  inset?: boolean
}) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{
        ...styles.itemText,
        color: colors.foreground,
      }}
    >
      <MenuPrimitive.RadioItem
        style={(state) => [
          styles.checkItem,
          state.highlighted && { opacity: 0.8 },
          props.disabled && { opacity: 0.5 },
          inset && styles.inset,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      >
        <View style={styles.indicator}>
          <MenuPrimitive.RadioItemIndicator>
            <Icon as={CheckIcon} size={16} />
          </MenuPrimitive.RadioItemIndicator>
        </View>
        {children}
      </MenuPrimitive.RadioItem>
    </TextStyleContext.Provider>
  )
}

function DropdownMenuLabel({
  style,
  inset,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
  inset?: boolean
}) {
  const { colors } = useTheme()
  return (
    <MenuPrimitive.GroupLabel
      style={(state) => [
        styles.label,
        { color: colors.mutedForeground },
        inset && styles.inset,
        resolveStyle(style, state) as StyleProp<TextStyle>,
      ]}
      {...props}
    />
  )
}

function DropdownMenuSeparator({
  style,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
  const { colors } = useTheme()
  return (
    <MenuPrimitive.Separator
      style={(state) => [
        styles.separator,
        { backgroundColor: colors.border },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/DropdownMenu.swift"
import PhosphorSwift
import SwiftUI

enum MenuPresentation: Sendable {
  case system, popup
}

extension EnvironmentValues {
  @Entry var menuPresentation: MenuPresentation = .system
  @Entry var menuRadio = MenuRadioContext()
}

struct MenuRadioContext: @unchecked Sendable {
  var selected: AnyHashable?
  var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}

public struct DropdownMenu<Label: View, Content: View>: View {
  let side: Edge
  let align: PopupAlignment
  let sideOffset: CGFloat
  let alignOffset: CGFloat
  let external: Binding<Bool>?
  let content: () -> Content
  let label: Label

  @State private var local = false

  public init(
    isPresented: Binding<Bool>? = nil,
    side: Edge = .bottom,
    align: PopupAlignment = .start,
    sideOffset: CGFloat = 4,
    alignOffset: CGFloat = 0,
    @ViewBuilder content: @escaping () -> Content,
    @ViewBuilder label: () -> Label
  ) {
    self.external = isPresented
    self.side = side
    self.align = align
    self.sideOffset = sideOffset
    self.alignOffset = alignOffset
    self.content = content
    self.label = label()
  }

  public var body: some View {
    let binding = external ?? $local
    Button {
      binding.wrappedValue = true
    } label: {
      label
    }
    .accessibilityHint(Text("Opens a menu"))
    .anchoredPopup(
      isPresented: binding,
      side: side,
      align: align,
      sideOffset: sideOffset,
      alignOffset: alignOffset,
      minWidth: 192
    ) {
      MenuPanel(content: content)
    }
  }
}

extension DropdownMenu where Label == Text {
  public init(
    _ title: LocalizedStringKey,
    isPresented: Binding<Bool>? = nil,
    side: Edge = .bottom,
    align: PopupAlignment = .start,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.init(isPresented: isPresented, side: side, align: align, content: content) { Text(title) }
  }
}

struct MenuPanel<Content: View>: View {
  @ViewBuilder let content: () -> Content

  var body: some View {
    let stack = VStack(alignment: .leading, spacing: 0) {
      content()
    }
    .padding(6)
    ViewThatFits(in: .vertical) {
      stack
      ScrollView { stack }
    }
    .scaledFont(TextStyle.base)
    .foregroundStyle(EsuissColor.foreground)
    .environment(\.menuPresentation, .popup)
    .popupSurface(radius: Radius.xl)
    .accessibilityElement(children: .contain)
  }
}

struct MenuRowStyle: ButtonStyle {
  var destructive = false
  var inset = false
  var trailing: CGFloat = 12
  var selected = false

  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .labelStyle(ButtonLabelStyle(spacing: 10, iconSize: 16))
      .lineLimit(1)
      .foregroundStyle(destructive ? EsuissColor.destructive : EsuissColor.foreground)
      .padding(.leading, inset ? 38 : 12)
      .padding(.trailing, trailing)
      .padding(.vertical, 8)
      .frame(maxWidth: .infinity, alignment: .leading)
      .background(selected ? EsuissColor.accent : .clear, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous))
      .opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.5)
      .contentShape(Rectangle())
  }
}

private struct MenuCheckmark: View {
  let visible: Bool

  var body: some View {
    Ph.check.regular
      .frame(width: 16, height: 16)
      .opacity(visible ? 1 : 0)
      .padding(.trailing, 8)
      .accessibilityHidden(true)
  }
}

public struct DropdownMenuItem<Label: View>: View {
  public enum Variant: CaseIterable, Sendable {
    case `default`, destructive
  }

  let variant: Variant
  let inset: Bool
  let action: () -> Void
  let label: Label

  @Environment(\.menuPresentation) private var presentation
  @Environment(\.overlayDismiss) private var dismiss

  public init(
    variant: Variant = .default,
    inset: Bool = false,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
  ) {
    self.variant = variant
    self.inset = inset
    self.action = action
    self.label = label()
  }

  public var body: some View {
    if presentation == .system {
      Button(role: variant == .destructive ? .destructive : nil, action: action) {
        label
      }
    } else {
      Button {
        action()
        dismiss?()
      } label: {
        HStack(spacing: 10) {
          label
        }
      }
      .buttonStyle(MenuRowStyle(destructive: variant == .destructive, inset: inset))
    }
  }
}

extension DropdownMenuItem where Label == Text {
  public init(
    _ title: LocalizedStringKey,
    variant: Variant = .default,
    inset: Bool = false,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, inset: inset, action: action) { Text(title) }
  }
}

extension DropdownMenuItem where Label == SwiftUI.Label<Text, Image> {
  public init(
    _ title: LocalizedStringKey,
    icon: Image,
    variant: Variant = .default,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, action: action) {
      SwiftUI.Label { Text(title) } icon: { icon }
    }
  }
}

public struct DropdownMenuCheckboxItem<Label: View>: View {
  let isOn: Binding<Bool>
  let inset: Bool
  let label: Label

  @Environment(\.menuPresentation) private var presentation

  public init(isOn: Binding<Bool>, inset: Bool = false, @ViewBuilder label: () -> Label) {
    self.isOn = isOn
    self.inset = inset
    self.label = label()
  }

  public var body: some View {
    if presentation == .system {
      Toggle(isOn: isOn) { label }
    } else {
      Button {
        isOn.wrappedValue.toggle()
      } label: {
        label
      }
      .buttonStyle(MenuRowStyle(inset: inset, trailing: 32))
      .overlay(alignment: .trailing) { MenuCheckmark(visible: isOn.wrappedValue) }
      .accessibilityAddTraits(isOn.wrappedValue ? [.isToggle, .isSelected] : .isToggle)
    }
  }
}

extension DropdownMenuCheckboxItem where Label == Text {
  public init(_ title: LocalizedStringKey, isOn: Binding<Bool>, inset: Bool = false) {
    self.init(isOn: isOn, inset: inset) { Text(title) }
  }
}

public struct DropdownMenuRadioGroup<Content: View>: View {
  let selection: Binding<AnyHashable>
  let content: Content

  public init<Value: Hashable & Sendable>(selection: Binding<Value>, @ViewBuilder content: () -> Content) {
    self.selection = Binding(
      get: { AnyHashable(selection.wrappedValue) },
      set: { value in
        if let value = value.base as? Value { selection.wrappedValue = value }
      }
    )
    self.content = content()
  }

  public var body: some View {
    let binding = selection
    content
      .environment(
        \.menuRadio,
        MenuRadioContext(selected: binding.wrappedValue, select: { binding.wrappedValue = $0 })
      )
  }
}

public struct DropdownMenuRadioItem<Label: View>: View {
  let value: AnyHashable
  let inset: Bool
  let label: Label

  @Environment(\.menuPresentation) private var presentation
  @Environment(\.menuRadio) private var radio

  public init<Value: Hashable & Sendable>(value: Value, inset: Bool = false, @ViewBuilder label: () -> Label) {
    self.value = AnyHashable(value)
    self.inset = inset
    self.label = label()
  }

  public var body: some View {
    let selected = radio.selected == value
    if presentation == .system {
      let radio = radio
      let value = value
      Toggle(isOn: Binding(get: { selected }, set: { if $0 { radio.select(value) } })) { label }
    } else {
      Button {
        radio.select(value)
      } label: {
        label
      }
      .buttonStyle(MenuRowStyle(inset: inset, trailing: 32))
      .overlay(alignment: .trailing) { MenuCheckmark(visible: selected) }
      .accessibilityAddTraits(selected ? .isSelected : [])
    }
  }
}

extension DropdownMenuRadioItem where Label == Text {
  public init<Value: Hashable & Sendable>(_ title: LocalizedStringKey, value: Value, inset: Bool = false) {
    self.init(value: value, inset: inset) { Text(title) }
  }
}

public struct DropdownMenuLabel: View {
  let title: LocalizedStringKey
  let inset: Bool

  @Environment(\.menuPresentation) private var presentation

  public init(_ title: LocalizedStringKey, inset: Bool = false) {
    self.title = title
    self.inset = inset
  }

  public var body: some View {
    if presentation == .system {
      Text(title)
    } else {
      Text(title)
        .scaledFont(TextStyle.xs, weight: .semibold)
        .foregroundStyle(EsuissColor.mutedForeground)
        .padding(.leading, inset ? 38 : 12)
        .padding(.trailing, 12)
        .padding(.vertical, 10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.isHeader)
    }
  }
}

public struct DropdownMenuGroup<Content: View>: View {
  let content: Content

  @Environment(\.menuPresentation) private var presentation

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    if presentation == .system {
      Section { content }
    } else {
      VStack(alignment: .leading, spacing: 0) { content }
    }
  }
}

public struct DropdownMenuSeparator: View {
  @Environment(\.menuPresentation) private var presentation

  public init() {}

  public var body: some View {
    if presentation == .system {
      Divider()
    } else {
      Rectangle()
        .fill(EsuissColor.border.opacity(0.5))
        .frame(height: 1)
        .padding(.vertical, 6)
        .padding(.horizontal, -6)
        .accessibilityHidden(true)
    }
  }
}

public struct DropdownMenuSub<Label: View, Content: View>: View {
  let inset: Bool
  let content: Content
  let label: Label

  @State private var open = false
  @Environment(\.menuPresentation) private var presentation
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(inset: Bool = false, @ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
    self.inset = inset
    self.content = content()
    self.label = label()
  }

  public var body: some View {
    if presentation == .system {
      Menu { content } label: { label }
    } else {
      VStack(alignment: .leading, spacing: 0) {
        Button {
          withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { open.toggle() }
        } label: {
          HStack(spacing: 8) {
            label
            Spacer(minLength: 16)
            Ph.caretRight.regular
              .frame(width: 16, height: 16)
              .rotationEffect(.degrees(open ? 90 : 0))
          }
        }
        .buttonStyle(MenuRowStyle(inset: inset, selected: open))
        .accessibilityValue(Text(open ? "Expanded" : "Collapsed"))
        if open {
          VStack(alignment: .leading, spacing: 0) { content }
            .padding(.leading, 12)
            .transition(.opacity)
        }
      }
    }
  }
}

extension DropdownMenuSub where Label == Text {
  public init(_ title: LocalizedStringKey, inset: Bool = false, @ViewBuilder content: () -> Content) {
    self.init(inset: inset, content: content) { Text(title) }
  }
}

extension DropdownMenuSub where Label == SwiftUI.Label<Text, Image> {
  public init(_ title: LocalizedStringKey, icon: Image, @ViewBuilder content: () -> Content) {
    self.init(content: content) {
      SwiftUI.Label { Text(title) } icon: { icon }
    }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/DropdownMenu.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretRight
import com.adamglin.phosphoricons.regular.Check
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class MenuItemVariant { Default, Destructive }

@Immutable
internal data class MenuContext(val dismiss: () -> Unit)

internal val LocalMenu = staticCompositionLocalOf { MenuContext {} }

@Immutable
private data class MenuRadioContext(val value: String?, val onValueChange: (String) -> Unit)

private val LocalMenuRadio = staticCompositionLocalOf { MenuRadioContext(null) {} }

@Composable
fun DropdownMenu(
    expanded: Boolean,
    onExpandedChange: (Boolean) -> Unit,
    trigger: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    side: PopupSide = PopupSide.Bottom,
    align: PopupAlign = PopupAlign.Start,
    sideOffset: Dp = 4.dp,
    alignOffset: Dp = 0.dp,
    content: @Composable ColumnScope.() -> Unit,
) {
    Box {
        trigger()
        MenuPopup(
            expanded = expanded,
            onDismissRequest = { onExpandedChange(false) },
            modifier = modifier,
            side = side,
            align = align,
            sideOffset = sideOffset,
            alignOffset = alignOffset,
            minWidth = 192.dp,
            content = content,
        )
    }
}

@Composable
internal fun MenuPopup(
    expanded: Boolean,
    onDismissRequest: () -> Unit,
    modifier: Modifier,
    side: PopupSide,
    align: PopupAlign,
    sideOffset: Dp,
    alignOffset: Dp,
    minWidth: Dp,
    point: IntOffset? = null,
    content: @Composable ColumnScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    AnchoredPopup(
        expanded = expanded,
        onDismissRequest = onDismissRequest,
        side = side,
        align = align,
        sideOffset = sideOffset,
        alignOffset = alignOffset,
        point = point,
    ) {
        CompositionLocalProvider(
            LocalMenu provides MenuContext(onDismissRequest),
            LocalContentStyle provides Typography.base.copy(color = colors.foreground),
            LocalContentColor provides colors.foreground,
            LocalIconSize provides 16.dp,
        ) {
            Column(
                modifier = modifier
                    .widthIn(min = minWidth)
                    .width(IntrinsicSize.Max)
                    .raisedSurface(colors, RoundedCornerShape(Radius.xl))
                    .verticalScroll(rememberScrollState())
                    .padding(6.dp),
                content = content,
            )
        }
    }
}

@Composable
private fun MenuRow(
    modifier: Modifier,
    enabled: Boolean,
    foreground: Color,
    restingBackground: Color,
    interaction: MutableInteractionSource,
    startPadding: Dp,
    endPadding: Dp,
    indicator: (@Composable () -> Unit)?,
    content: @Composable RowScope.() -> Unit,
) {
    val pressed by interaction.collectIsPressedAsState()
    val shape = RoundedCornerShape(Radius.md)
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = 44.dp)
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clip(shape)
            .background(restingBackground, shape)
            .then(modifier),
        contentAlignment = Alignment.CenterEnd,
    ) {
        CompositionLocalProvider(
            LocalContentStyle provides Typography.base.copy(color = foreground),
            LocalContentColor provides foreground,
            LocalIconSize provides 16.dp,
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .heightIn(min = 44.dp)
                    .padding(start = startPadding, end = endPadding, top = 8.dp, bottom = 8.dp),
                horizontalArrangement = Arrangement.spacedBy(10.dp),
                verticalAlignment = Alignment.CenterVertically,
                content = content,
            )
            if (indicator != null) {
                Box(
                    modifier = Modifier.padding(start = 12.dp, end = 8.dp),
                    contentAlignment = Alignment.Center,
                ) {
                    indicator()
                }
            }
        }
    }
}

@Composable
private fun menuForeground(destructive: Boolean = false): Color {
    val colors = EsuissTheme.colors
    return if (destructive) colors.destructive else colors.foreground
}

@Composable
fun DropdownMenuItem(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: MenuItemVariant = MenuItemVariant.Default,
    enabled: Boolean = true,
    inset: Boolean = false,
    closeOnClick: Boolean = true,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val menu = LocalMenu.current
    val interaction = remember { MutableInteractionSource() }
    val destructive = variant == MenuItemVariant.Destructive
    MenuRow(
        modifier = modifier.clickable(
            interactionSource = interaction,
            indication = null,
            enabled = enabled,
            role = Role.Button,
            onClick = {
                onClick()
                if (closeOnClick) menu.dismiss()
            },
        ),
        enabled = enabled,
        foreground = menuForeground(destructive),
        restingBackground = Color.Transparent,
        interaction = interaction,
        startPadding = if (inset) 38.dp else 12.dp,
        endPadding = 12.dp,
        indicator = null,
        content = content,
    )
}

@Composable
fun DropdownMenuItem(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    icon: ImageVector? = null,
    variant: MenuItemVariant = MenuItemVariant.Default,
    enabled: Boolean = true,
    inset: Boolean = false,
    closeOnClick: Boolean = true,
) {
    DropdownMenuItem(onClick, modifier, variant, enabled, inset, closeOnClick) {
        if (icon != null) Icon(icon, contentDescription = null)
        BasicText(text, style = LocalContentStyle.current, maxLines = 1)
    }
}

@Composable
private fun CheckIndicator(visible: Boolean) {
    Box(modifier = Modifier.width(16.dp)) {
        if (visible) Icon(PhosphorIcons.Regular.Check, contentDescription = null, size = 16.dp)
    }
}

@Composable
fun DropdownMenuCheckboxItem(
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    inset: Boolean = false,
    closeOnClick: Boolean = false,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val menu = LocalMenu.current
    val interaction = remember { MutableInteractionSource() }
    MenuRow(
        modifier = modifier.toggleable(
            value = checked,
            interactionSource = interaction,
            indication = null,
            enabled = enabled,
            role = Role.Checkbox,
            onValueChange = {
                onCheckedChange(it)
                if (closeOnClick) menu.dismiss()
            },
        ),
        enabled = enabled,
        foreground = menuForeground(),
        restingBackground = Color.Transparent,
        interaction = interaction,
        startPadding = if (inset) 38.dp else 12.dp,
        endPadding = 32.dp,
        indicator = { CheckIndicator(checked) },
        content = content,
    )
}

@Composable
fun DropdownMenuCheckboxItem(
    text: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    inset: Boolean = false,
) {
    DropdownMenuCheckboxItem(checked, onCheckedChange, modifier, enabled, inset) {
        BasicText(text, style = LocalContentStyle.current, maxLines = 1)
    }
}

@Composable
fun DropdownMenuRadioGroup(
    value: String?,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    CompositionLocalProvider(LocalMenuRadio provides MenuRadioContext(value, onValueChange)) {
        Column(modifier = modifier.fillMaxWidth().selectableGroup(), content = content)
    }
}

@Composable
fun DropdownMenuRadioItem(
    value: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    inset: Boolean = false,
    closeOnClick: Boolean = false,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val menu = LocalMenu.current
    val group = LocalMenuRadio.current
    val selected = group.value == value
    val interaction = remember { MutableInteractionSource() }
    MenuRow(
        modifier = modifier.selectable(
            selected = selected,
            interactionSource = interaction,
            indication = null,
            enabled = enabled,
            role = Role.RadioButton,
            onClick = {
                group.onValueChange(value)
                if (closeOnClick) menu.dismiss()
            },
        ),
        enabled = enabled,
        foreground = menuForeground(),
        restingBackground = Color.Transparent,
        interaction = interaction,
        startPadding = if (inset) 38.dp else 12.dp,
        endPadding = 32.dp,
        indicator = { CheckIndicator(selected) },
        content = content,
    )
}

@Composable
fun DropdownMenuRadioItem(
    value: String,
    text: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    inset: Boolean = false,
) {
    DropdownMenuRadioItem(value, modifier, enabled, inset) {
        BasicText(text, style = LocalContentStyle.current, maxLines = 1)
    }
}

@Composable
fun DropdownMenuLabel(
    text: String,
    modifier: Modifier = Modifier,
    inset: Boolean = false,
) {
    BasicText(
        text,
        modifier = modifier
            .fillMaxWidth()
            .semantics { heading() }
            .padding(start = if (inset) 38.dp else 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
        style = Typography.xs.copy(color = EsuissTheme.colors.mutedForeground, fontWeight = FontWeight.SemiBold),
    )
}

@Composable
fun DropdownMenuSeparator(modifier: Modifier = Modifier) {
    MenuSeparatorLine(modifier, bleed = 6.dp, color = EsuissTheme.colors.border.copy(alpha = 0.5f))
}

@Composable
internal fun MenuSeparatorLine(modifier: Modifier, bleed: Dp, color: Color) {
    Box(
        modifier = modifier
            .padding(vertical = bleed)
            .layout { measurable, constraints ->
                val extra = bleed.roundToPx()
                val width = if (constraints.hasBoundedWidth) constraints.maxWidth + extra * 2 else constraints.minWidth
                val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
                layout(constraints.constrainWidth(placeable.width - extra * 2), placeable.height) {
                    placeable.place(-extra, 0)
                }
            }
            .height(1.dp)
            .background(color),
    )
}

@Composable
fun DropdownMenuGroup(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(modifier = modifier.fillMaxWidth(), content = content)
}

@Composable
fun DropdownMenuSub(
    text: String,
    modifier: Modifier = Modifier,
    icon: ImageVector? = null,
    enabled: Boolean = true,
    inset: Boolean = false,
    content: @Composable ColumnScope.() -> Unit,
) {
    DropdownMenuSub(
        trigger = {
            if (icon != null) Icon(icon, contentDescription = null)
            BasicText(text, style = LocalContentStyle.current, maxLines = 1)
        },
        modifier = modifier,
        enabled = enabled,
        inset = inset,
        content = content,
    )
}

@Composable
fun DropdownMenuSub(
    trigger: @Composable RowScope.() -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    inset: Boolean = false,
    content: @Composable ColumnScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val menu = LocalMenu.current
    var open by remember { mutableStateOf(false) }
    val interaction = remember { MutableInteractionSource() }
    val rotation by animateFloatAsState(if (open) 90f else 0f, label = "caret")
    Box {
        MenuRow(
            modifier = modifier
                .semantics { stateDescription = if (open) "Expanded" else "Collapsed" }
                .clickable(
                    interactionSource = interaction,
                    indication = null,
                    enabled = enabled,
                    role = Role.Button,
                    onClick = { open = !open },
                ),
            enabled = enabled,
            foreground = menuForeground(),
            restingBackground = if (open) colors.accent else Color.Transparent,
            interaction = interaction,
            startPadding = if (inset) 38.dp else 12.dp,
            endPadding = 12.dp,
            indicator = null,
        ) {
            Row(
                modifier = Modifier.weight(1f),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically,
                content = trigger,
            )
            Icon(PhosphorIcons.Regular.CaretRight, contentDescription = null, modifier = Modifier.rotate(rotation), size = 16.dp)
        }
        MenuPopup(
            expanded = open,
            onDismissRequest = { open = false },
            modifier = Modifier,
            side = PopupSide.Right,
            align = PopupAlign.Start,
            sideOffset = 0.dp,
            alignOffset = (-6).dp,
            minWidth = 144.dp,
        ) {
            CompositionLocalProvider(
                LocalMenu provides MenuContext(
                    dismiss = {
                        open = false
                        menu.dismiss()
                    },
                ),
            ) {
                content()
            }
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { DropdownMenuContent, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
```

```tsx
<View className="items-start">
  <DropdownMenu defaultOpen>
    <OpenButton />
    <DropdownMenuContent className="w-56" align="start">
      <DropdownMenuLabel>My account</DropdownMenuLabel>
      <DropdownMenuGroup>
        <DropdownMenuItem>
          <Icon as={UserIcon} className="size-4" />
          <Text>Profile</Text>
        </DropdownMenuItem>
        <DropdownMenuItem>
          <Icon as={CreditCardIcon} className="size-4" />
          <Text>Billing</Text>
        </DropdownMenuItem>
        <DropdownMenuItem>
          <Icon as={GearIcon} className="size-4" />
          <Text>Settings</Text>
        </DropdownMenuItem>
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuSub>
        <DropdownMenuSubTrigger>
          <View className="flex-row items-center gap-2">
            <Icon as={UserPlusIcon} className="size-4" />
            <Text>Invite users</Text>
          </View>
        </DropdownMenuSubTrigger>
        <DropdownMenuSubContent>
          <DropdownMenuItem>
            <Icon as={EnvelopeIcon} className="size-4" />
            <Text>Email</Text>
          </DropdownMenuItem>
          <DropdownMenuItem>
            <Icon as={ChatCircleIcon} className="size-4" />
            <Text>Message</Text>
          </DropdownMenuItem>
        </DropdownMenuSubContent>
      </DropdownMenuSub>
      <DropdownMenuSeparator />
      <DropdownMenuItem variant="destructive">
        <Icon as={SignOutIcon} className="size-4" />
        <Text>Log out</Text>
      </DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</View>
```

Use `render` on `DropdownMenuTrigger` to render it as a `Button`. Items close the menu when pressed unless `closeOnPress={false}`.

Use `DropdownMenuCheckboxItem` with `checked` and `onCheckedChange`, and `DropdownMenuRadioItem` inside a `DropdownMenuRadioGroup` with `value` and `onValueChange`. Group items with `DropdownMenuGroup` and `DropdownMenuLabel`, and divide sections with `DropdownMenuSeparator`.

Set `variant="destructive"` on an item that deletes or removes. Use `inset` on items without an icon to align them with items that have one. Position the content with `side`, `align`, `sideOffset` and `alignOffset`.

## Examples

### Default

![Preview](https://ui.suiss.app/previews/components-dropdown-menu--default.light.webp)

```tsx
import { ChatCircleIcon, CreditCardIcon, EnvelopeIcon, GearIcon, SignOutIcon, UserIcon, UserPlusIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function OpenButton({ label = "Open menu" }: { label?: string }) {
  return (
    <DropdownMenuTrigger render={<Button variant="outline" />}>
      <Text>{label}</Text>
    </DropdownMenuTrigger>
  )
}

export function DropdownMenuExample() {
  return (
    <View className="items-start">
      <DropdownMenu>
        <OpenButton />
        <DropdownMenuContent className="w-56" align="start">
          <DropdownMenuLabel>My account</DropdownMenuLabel>
          <DropdownMenuGroup>
            <DropdownMenuItem>
              <Icon as={UserIcon} className="size-4" />
              <Text>Profile</Text>
            </DropdownMenuItem>
            <DropdownMenuItem>
              <Icon as={CreditCardIcon} className="size-4" />
              <Text>Billing</Text>
            </DropdownMenuItem>
            <DropdownMenuItem>
              <Icon as={GearIcon} className="size-4" />
              <Text>Settings</Text>
            </DropdownMenuItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>
              <View className="flex-row items-center gap-2">
                <Icon as={UserPlusIcon} className="size-4" />
                <Text>Invite users</Text>
              </View>
            </DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuItem>
                <Icon as={EnvelopeIcon} className="size-4" />
                <Text>Email</Text>
              </DropdownMenuItem>
              <DropdownMenuItem>
                <Icon as={ChatCircleIcon} className="size-4" />
                <Text>Message</Text>
              </DropdownMenuItem>
            </DropdownMenuSubContent>
          </DropdownMenuSub>
          <DropdownMenuSeparator />
          <DropdownMenuItem variant="destructive">
            <Icon as={SignOutIcon} className="size-4" />
            <Text>Log out</Text>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct DropdownMenuAccount: View {
  @State private var open: Bool

  init(open: Bool = false) {
    _open = State(initialValue: open)
  }

  var body: some View {
    DropdownMenu(isPresented: $open) {
      DropdownMenuLabel("My account")
      DropdownMenuGroup {
        DropdownMenuItem("Profile", icon: Ph.user.regular) {}
        DropdownMenuItem("Billing", icon: Ph.creditCard.regular) {}
        DropdownMenuItem("Settings", icon: Ph.gear.regular) {}
      }
      DropdownMenuSeparator()
      DropdownMenuSub("Invite users", icon: Ph.userPlus.regular) {
        DropdownMenuItem("Email", icon: Ph.envelope.regular) {}
        DropdownMenuItem("Message", icon: Ph.chatCircle.regular) {}
      }
      DropdownMenuSeparator()
      DropdownMenuItem("Log out", icon: Ph.signOut.regular, variant: .destructive) {}
    } label: {
      Text("Open menu")
    }
    .buttonStyle(.variant(.outline))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.CreditCard
import com.adamglin.phosphoricons.regular.Envelope
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.SignOut
import com.adamglin.phosphoricons.regular.User
import com.adamglin.phosphoricons.regular.UserPlus
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.DropdownMenu
import com.esuiss.ui.components.DropdownMenuGroup
import com.esuiss.ui.components.DropdownMenuItem
import com.esuiss.ui.components.DropdownMenuLabel
import com.esuiss.ui.components.DropdownMenuSeparator
import com.esuiss.ui.components.DropdownMenuSub
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.PopupAlign

@Composable
fun DropdownMenuExample() {
    DropdownMenuAccount(defaultOpen = false)
}

@Composable
fun DropdownMenuDemo(
    label: String,
    defaultOpen: Boolean = false,
    content: @Composable () -> Unit,
) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        DropdownMenu(
            expanded = open,
            onExpandedChange = { open = it },
            trigger = { Button(label, onClick = { open = !open }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(224.dp),
            align = PopupAlign.Start,
        ) {
            content()
        }
    }
}

@Composable
fun DropdownMenuAccount(defaultOpen: Boolean) {
    DropdownMenuDemo("Open menu", defaultOpen) {
        DropdownMenuLabel("My account")
        DropdownMenuGroup {
            DropdownMenuItem("Profile", onClick = {}, icon = PhosphorIcons.Regular.User)
            DropdownMenuItem("Billing", onClick = {}, icon = PhosphorIcons.Regular.CreditCard)
            DropdownMenuItem("Settings", onClick = {}, icon = PhosphorIcons.Regular.Gear)
        }
        DropdownMenuSeparator()
        DropdownMenuSub("Invite users", icon = PhosphorIcons.Regular.UserPlus) {
            DropdownMenuItem("Email", onClick = {}, icon = PhosphorIcons.Regular.Envelope)
            DropdownMenuItem("Message", onClick = {}, icon = PhosphorIcons.Regular.ChatCircle)
        }
        DropdownMenuSeparator()
        DropdownMenuItem("Log out", onClick = {}, icon = PhosphorIcons.Regular.SignOut, variant = MenuItemVariant.Destructive)
    }
}
```

### Checkboxes

Checkbox items that toggle panels on and off.

![Preview](https://ui.suiss.app/previews/components-dropdown-menu--checkboxes.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Text } from "@/components/ui/text"

function OpenButton({ label = "Open menu" }: { label?: string }) {
  return (
    <DropdownMenuTrigger render={<Button variant="outline" />}>
      <Text>{label}</Text>
    </DropdownMenuTrigger>
  )
}

function CheckboxesDemo() {
  const [statusBar, setStatusBar] = React.useState(true)
  const [activityBar, setActivityBar] = React.useState(false)
  const [panel, setPanel] = React.useState(false)
  return (
    <View className="items-start">
      <DropdownMenu>
        <OpenButton label="View" />
        <DropdownMenuContent className="w-56" align="start">
          <DropdownMenuLabel>Appearance</DropdownMenuLabel>
          <DropdownMenuSeparator />
          <DropdownMenuCheckboxItem
            checked={statusBar}
            onCheckedChange={setStatusBar}
          >
            <Text>Status bar</Text>
          </DropdownMenuCheckboxItem>
          <DropdownMenuCheckboxItem
            checked={activityBar}
            onCheckedChange={setActivityBar}
            disabled
          >
            <Text>Activity bar</Text>
          </DropdownMenuCheckboxItem>
          <DropdownMenuCheckboxItem checked={panel} onCheckedChange={setPanel}>
            <Text>Panel</Text>
          </DropdownMenuCheckboxItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </View>
  )
}

export function DropdownMenuCheckboxesExample() {
  return (
    <CheckboxesDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DropdownMenuCheckboxesDemo: View {
  @State private var statusBar = true
  @State private var activityBar = false
  @State private var panel = false

  var body: some View {
    DropdownMenu {
      DropdownMenuLabel("Appearance")
      DropdownMenuSeparator()
      DropdownMenuCheckboxItem("Status bar", isOn: $statusBar)
      DropdownMenuCheckboxItem("Activity bar", isOn: $activityBar)
        .disabled(true)
      DropdownMenuCheckboxItem("Panel", isOn: $panel)
    } label: {
      Text("View")
    }
    .buttonStyle(.variant(.outline))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.DropdownMenu
import com.esuiss.ui.components.DropdownMenuCheckboxItem
import com.esuiss.ui.components.DropdownMenuLabel
import com.esuiss.ui.components.DropdownMenuSeparator
import com.esuiss.ui.components.PopupAlign

@Composable
fun DropdownMenuDemo(
    label: String,
    defaultOpen: Boolean = false,
    content: @Composable () -> Unit,
) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        DropdownMenu(
            expanded = open,
            onExpandedChange = { open = it },
            trigger = { Button(label, onClick = { open = !open }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(224.dp),
            align = PopupAlign.Start,
        ) {
            content()
        }
    }
}

@Composable
fun DropdownMenuCheckboxes() {
    var statusBar by rememberSaveable { mutableStateOf(true) }
    var activityBar by rememberSaveable { mutableStateOf(false) }
    var panel by rememberSaveable { mutableStateOf(false) }
    DropdownMenuDemo("View") {
        DropdownMenuLabel("Appearance")
        DropdownMenuSeparator()
        DropdownMenuCheckboxItem("Status bar", checked = statusBar, onCheckedChange = { statusBar = it })
        DropdownMenuCheckboxItem("Activity bar", checked = activityBar, onCheckedChange = { activityBar = it }, enabled = false)
        DropdownMenuCheckboxItem("Panel", checked = panel, onCheckedChange = { panel = it })
    }
}
```

### Radio group

Radio items that pick one value.

![Preview](https://ui.suiss.app/previews/components-dropdown-menu--radio-group.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Text } from "@/components/ui/text"

function OpenButton({ label = "Open menu" }: { label?: string }) {
  return (
    <DropdownMenuTrigger render={<Button variant="outline" />}>
      <Text>{label}</Text>
    </DropdownMenuTrigger>
  )
}

function RadioGroupDemo() {
  const [position, setPosition] = React.useState("bottom")
  return (
    <View className="items-start">
      <DropdownMenu>
        <OpenButton label="Panel position" />
        <DropdownMenuContent className="w-56" align="start">
          <DropdownMenuLabel>Panel position</DropdownMenuLabel>
          <DropdownMenuSeparator />
          <DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
            <DropdownMenuRadioItem value="top">
              <Text>Top</Text>
            </DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="bottom">
              <Text>Bottom</Text>
            </DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="right">
              <Text>Right</Text>
            </DropdownMenuRadioItem>
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenu>
    </View>
  )
}

export function DropdownMenuRadioGroupExample() {
  return (
    <RadioGroupDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DropdownMenuRadioGroupDemo: View {
  @State private var position = "bottom"

  var body: some View {
    DropdownMenu {
      DropdownMenuLabel("Panel position")
      DropdownMenuSeparator()
      DropdownMenuRadioGroup(selection: $position) {
        DropdownMenuRadioItem("Top", value: "top")
        DropdownMenuRadioItem("Bottom", value: "bottom")
        DropdownMenuRadioItem("Right", value: "right")
      }
    } label: {
      Text("Panel position")
    }
    .buttonStyle(.variant(.outline))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.DropdownMenu
import com.esuiss.ui.components.DropdownMenuLabel
import com.esuiss.ui.components.DropdownMenuRadioGroup
import com.esuiss.ui.components.DropdownMenuRadioItem
import com.esuiss.ui.components.DropdownMenuSeparator
import com.esuiss.ui.components.PopupAlign

@Composable
fun DropdownMenuDemo(
    label: String,
    defaultOpen: Boolean = false,
    content: @Composable () -> Unit,
) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        DropdownMenu(
            expanded = open,
            onExpandedChange = { open = it },
            trigger = { Button(label, onClick = { open = !open }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(224.dp),
            align = PopupAlign.Start,
        ) {
            content()
        }
    }
}

@Composable
fun DropdownMenuRadio() {
    var position by rememberSaveable { mutableStateOf("bottom") }
    DropdownMenuDemo("Panel position") {
        DropdownMenuLabel("Panel position")
        DropdownMenuSeparator()
        DropdownMenuRadioGroup(value = position, onValueChange = { position = it }) {
            DropdownMenuRadioItem("top", "Top")
            DropdownMenuRadioItem("bottom", "Bottom")
            DropdownMenuRadioItem("right", "Right")
        }
    }
}
```

### Inset

Items aligned with `inset`.

![Preview](https://ui.suiss.app/previews/components-dropdown-menu--inset.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Text } from "@/components/ui/text"

function OpenButton({ label = "Open menu" }: { label?: string }) {
  return (
    <DropdownMenuTrigger render={<Button variant="outline" />}>
      <Text>{label}</Text>
    </DropdownMenuTrigger>
  )
}

export function DropdownMenuInsetExample() {
  return (
    <View className="items-start">
      <DropdownMenu>
        <OpenButton />
        <DropdownMenuContent className="w-56" align="start">
          <DropdownMenuLabel inset>Actions</DropdownMenuLabel>
          <DropdownMenuItem inset>
            <Text>Copy</Text>
          </DropdownMenuItem>
          <DropdownMenuItem inset>
            <Text>Paste</Text>
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem inset variant="destructive">
            <Text>Delete</Text>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DropdownMenuInsetExample: View {
  var body: some View {
    DropdownMenu {
      DropdownMenuLabel("Actions", inset: true)
      DropdownMenuItem("Copy", inset: true) {}
      DropdownMenuItem("Paste", inset: true) {}
      DropdownMenuSeparator()
      DropdownMenuItem("Delete", variant: .destructive, inset: true) {}
    } label: {
      Text("Open menu")
    }
    .buttonStyle(.variant(.outline))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.DropdownMenu
import com.esuiss.ui.components.DropdownMenuItem
import com.esuiss.ui.components.DropdownMenuLabel
import com.esuiss.ui.components.DropdownMenuSeparator
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.PopupAlign

@Composable
fun DropdownMenuDemo(
    label: String,
    defaultOpen: Boolean = false,
    content: @Composable () -> Unit,
) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        DropdownMenu(
            expanded = open,
            onExpandedChange = { open = it },
            trigger = { Button(label, onClick = { open = !open }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(224.dp),
            align = PopupAlign.Start,
        ) {
            content()
        }
    }
}

@Composable
fun DropdownMenuInset() {
    DropdownMenuDemo("Open menu") {
        DropdownMenuLabel("Actions", inset = true)
        DropdownMenuItem("Copy", onClick = {}, inset = true)
        DropdownMenuItem("Paste", onClick = {}, inset = true)
        DropdownMenuSeparator()
        DropdownMenuItem("Delete", onClick = {}, inset = true, variant = MenuItemVariant.Destructive)
    }
}
```

## API Reference

### DropdownMenu

The root that manages the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the menu is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the menu starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean, details) => void` | - | Called when the menu opens or closes. |
| `modal` | `boolean` | `true` | Blocks interaction with the rest of the screen while open. |
| `disabled` | `boolean` | `false` | Prevents the menu from opening. |

### DropdownMenuContent

Floating menu card.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of the portal host to render into. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.Popup>`, `PositionerProps`.

### DropdownMenuSubTrigger

Row that expands the submenu.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |
| `iconClassName` | `string` | - | Extra classes for the caret icon. |
| `inset` | `boolean` | - | Adds start padding. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger>`.

### DropdownMenuSubContent

Items of the submenu.

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.Popup>`.

### DropdownMenuItem

Action row.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds start padding to align with icon items. |
| `variant` | `"default" \| "destructive"` | `"default"` | `default` or `destructive`. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.Item>`.

### DropdownMenuCheckboxItem

Row with a check mark that toggles.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |
| `inset` | `boolean` | - | Adds start padding. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.CheckboxItem>`.

### DropdownMenuRadioItem

Row that selects one value in its group.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |
| `inset` | `boolean` | - | Adds start padding. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.RadioItem>`.

### DropdownMenuLabel

Heading for a group.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inset` | `boolean` | - | Adds start padding. |

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.GroupLabel>`.

### DropdownMenuSeparator

Line between sections.

Also accepts the props of `React.ComponentProps<typeof MenuPrimitive.Separator>`.

## Accessibility

- The trigger has the `button` role.
- The popup has the `menu` role; items use `menuitem`, `checkbox` and `radio` roles, and radio groups use `radiogroup`.
- Groups are labelled by their `DropdownMenuLabel`, and separators have the `separator` role.

## Notes

- Uses `bg-popover`, and `bg-accent` for an open submenu trigger; a highlighted item fades to `opacity-80` and a disabled one to `opacity-50`; light and dark follow the system appearance.
- The fade-in respects the system Reduce Motion setting.

