# Action Menu

A list of actions that opens as a dropdown menu on desktop and an action sheet on mobile.

Action Menu is an open-source pattern from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/action-menu

**React**

**Example**

```tsx
import {
  ActionMenu,
  ActionMenuContent,
  ActionMenuItem,
  ActionMenuLabel,
  ActionMenuSeparator,
  ActionMenuTrigger,
} from "@/components/patterns/action-menu"
import { Button } from "@/components/ui/button"

export default function ActionMenuDemo() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <ActionMenu>
        <ActionMenuTrigger render={<Button variant="outline" />}>
          Order actions
        </ActionMenuTrigger>
        <ActionMenuContent>
          <ActionMenuLabel>Order #1042</ActionMenuLabel>
          <ActionMenuItem>Edit</ActionMenuItem>
          <ActionMenuItem>Duplicate</ActionMenuItem>
          <ActionMenuItem>Share</ActionMenuItem>
          <ActionMenuSeparator />
          <ActionMenuItem variant="destructive">Delete</ActionMenuItem>
        </ActionMenuContent>
      </ActionMenu>
    </div>
  )
}
```

`ActionMenu` renders a `DropdownMenu` on desktop and an `AlertSheet` below the `48rem` breakpoint (`useIsMobile`). Each part switches to its matching component: items become `DropdownMenuItem`s or `AlertSheetAction`s, and the label becomes a menu label or the sheet title. On mobile, the content wraps the items in a grouped card and appends a Cancel button.

Use it for a short set of actions on a single object, such as editing, sharing or deleting a record, where an iOS-style action sheet suits touch screens.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/action-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 patterns action-menu
```

### Manual

1. Add the pieces it builds on: [Alert Sheet](https://ui.suiss.app/docs/components/alert-sheet), [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu), `use-mobile`.

2. Copy the source into `components/patterns/action-menu.tsx`:

```tsx title="components/patterns/action-menu.tsx"
"use client"

import * as React from "react"

import {
  AlertSheet,
  AlertSheetAction,
  AlertSheetCancel,
  AlertSheetContent,
  AlertSheetGroup,
  AlertSheetHeader,
  AlertSheetTitle,
  AlertSheetTrigger,
} from "@/components/ui/alert-sheet"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useIsMobile } from "@/hooks/use-mobile"

type ActionMenuContextProps = {
  isMobile: boolean
}

const ActionMenuContext = React.createContext<ActionMenuContextProps | null>(
  null
)

function useActionMenu() {
  const context = React.useContext(ActionMenuContext)

  if (!context) {
    throw new Error("useActionMenu must be used within an <ActionMenu />")
  }

  return context
}

function ActionMenu({
  open,
  defaultOpen,
  onOpenChange,
  dismissible = true,
  children,
}: Pick<
  React.ComponentProps<typeof DropdownMenu>,
  "open" | "defaultOpen" | "children"
> & {
  onOpenChange?: (open: boolean) => void
  dismissible?: boolean
}) {
  const isMobile = useIsMobile()
  const rootProps = {
    open,
    defaultOpen,
    onOpenChange: (next: boolean) => onOpenChange?.(next),
    children,
  }

  return (
    <ActionMenuContext.Provider value={{ isMobile }}>
      {isMobile ? (
        <AlertSheet
          data-slot="action-menu"
          dismissible={dismissible}
          {...rootProps}
        />
      ) : (
        <DropdownMenu data-slot="action-menu" {...rootProps} />
      )}
    </ActionMenuContext.Provider>
  )
}

function ActionMenuTrigger(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    render?: React.ReactElement
  }
) {
  const { isMobile } = useActionMenu()
  const Trigger = isMobile ? AlertSheetTrigger : DropdownMenuTrigger

  return <Trigger data-slot="action-menu-trigger" {...props} />
}

function ActionMenuContent({
  cancelLabel = "Cancel",
  align,
  alignOffset,
  side,
  sideOffset,
  className,
  children,
  ...props
}: React.HTMLAttributes<HTMLElement> &
  Pick<
    React.ComponentProps<typeof DropdownMenuContent>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    cancelLabel?: string
  }) {
  const { isMobile } = useActionMenu()

  if (isMobile) {
    return (
      <AlertSheetContent
        data-slot="action-menu-content"
        className={className}
        {...props}
      >
        <AlertSheetGroup>{children}</AlertSheetGroup>
        <AlertSheetCancel>{cancelLabel}</AlertSheetCancel>
      </AlertSheetContent>
    )
  }

  return (
    <DropdownMenuContent
      data-slot="action-menu-content"
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      className={className}
      {...props}
    >
      {children}
    </DropdownMenuContent>
  )
}

function ActionMenuLabel({
  className,
  children,
  ...props
}: React.HTMLAttributes<HTMLElement>) {
  const { isMobile } = useActionMenu()

  if (isMobile) {
    return (
      <AlertSheetHeader data-slot="action-menu-label">
        <AlertSheetTitle className={className} {...props}>
          {children}
        </AlertSheetTitle>
      </AlertSheetHeader>
    )
  }

  return (
    <DropdownMenuGroup>
      <DropdownMenuLabel
        data-slot="action-menu-label"
        className={className}
        {...props}
      >
        {children}
      </DropdownMenuLabel>
    </DropdownMenuGroup>
  )
}

function ActionMenuItem({
  variant,
  disabled,
  ...props
}: React.HTMLAttributes<HTMLElement> & {
  variant?: "default" | "destructive"
  disabled?: boolean
}) {
  const { isMobile } = useActionMenu()
  const Item = isMobile ? AlertSheetAction : DropdownMenuItem

  return (
    <Item
      data-slot="action-menu-item"
      variant={variant}
      disabled={disabled}
      {...props}
    />
  )
}

function ActionMenuSeparator(props: React.HTMLAttributes<HTMLElement>) {
  const { isMobile } = useActionMenu()

  if (isMobile) return null

  return <DropdownMenuSeparator data-slot="action-menu-separator" {...props} />
}

export {
  ActionMenu,
  ActionMenuContent,
  ActionMenuItem,
  ActionMenuLabel,
  ActionMenuSeparator,
  ActionMenuTrigger,
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { ActionMenu, ActionMenuTrigger, ActionMenuContent, ActionMenuLabel, ActionMenuItem, ActionMenuSeparator } from "@/components/patterns/action-menu"
```

```tsx
<div className="flex min-h-svh items-center justify-center">
  <ActionMenu>
    <ActionMenuTrigger render={<Button variant="outline" />}>
      Order actions
    </ActionMenuTrigger>
    <ActionMenuContent>
      <ActionMenuLabel>Order #1042</ActionMenuLabel>
      <ActionMenuItem>Edit</ActionMenuItem>
      <ActionMenuItem>Duplicate</ActionMenuItem>
      <ActionMenuItem>Share</ActionMenuItem>
      <ActionMenuSeparator />
      <ActionMenuItem variant="destructive">Delete</ActionMenuItem>
    </ActionMenuContent>
  </ActionMenu>
</div>
```

Pass `render` to `ActionMenuTrigger` to use your own button, for example a `Button` with `variant="outline"`. `open`, `defaultOpen` and `onOpenChange` work on both layouts, so the menu can be controlled or uncontrolled. `align`, `alignOffset`, `side` and `sideOffset` on `ActionMenuContent` position the desktop popup and are ignored on mobile.

## Examples

### With icons

Places an icon before every action, including a destructive Delete item.

**Example**

```tsx
import {
  CopyIcon,
  PencilSimpleIcon,
  ShareIcon,
  TrashIcon,
} from "@phosphor-icons/react"
import {
  ActionMenu,
  ActionMenuContent,
  ActionMenuItem,
  ActionMenuSeparator,
  ActionMenuTrigger,
} from "@/components/patterns/action-menu"
import { Button } from "@/components/ui/button"

export default function ActionMenuWithIcons() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <ActionMenu>
        <ActionMenuTrigger render={<Button variant="outline" />}>
          Order actions
        </ActionMenuTrigger>
        <ActionMenuContent>
          <ActionMenuItem>
            <PencilSimpleIcon />
            Edit
          </ActionMenuItem>
          <ActionMenuItem>
            <CopyIcon />
            Duplicate
          </ActionMenuItem>
          <ActionMenuItem>
            <ShareIcon />
            Share
          </ActionMenuItem>
          <ActionMenuSeparator />
          <ActionMenuItem variant="destructive">
            <TrashIcon />
            Delete
          </ActionMenuItem>
        </ActionMenuContent>
      </ActionMenu>
    </div>
  )
}
```

### Not dismissible

Sets `dismissible` to `false` so the mobile sheet closes only through an action or Cancel.

**Example**

```tsx
import {
  ActionMenu,
  ActionMenuContent,
  ActionMenuItem,
  ActionMenuLabel,
  ActionMenuSeparator,
  ActionMenuTrigger,
} from "@/components/patterns/action-menu"
import { Button } from "@/components/ui/button"

export default function ActionMenuNotDismissible() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <ActionMenu dismissible={false}>
        <ActionMenuTrigger render={<Button variant="outline" />}>
          Order actions
        </ActionMenuTrigger>
        <ActionMenuContent>
          <ActionMenuLabel>Order #1042</ActionMenuLabel>
          <ActionMenuItem>Edit</ActionMenuItem>
          <ActionMenuItem>Duplicate</ActionMenuItem>
          <ActionMenuItem>Share</ActionMenuItem>
          <ActionMenuSeparator />
          <ActionMenuItem variant="destructive">Delete</ActionMenuItem>
        </ActionMenuContent>
      </ActionMenu>
    </div>
  )
}
```

## API Reference

- `ActionMenu`
- `ActionMenuTrigger`
- `ActionMenuContent`
  - `ActionMenuLabel`
  - `ActionMenuItem`
  - `ActionMenuSeparator`

### ActionMenu

The root that renders a `DropdownMenu` on desktop and an `AlertSheet` on mobile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the menu or sheet opens or closes. |
| `dismissible` | `boolean` | `true` | When `true`, the mobile sheet closes on an outside press; when `false`, it uses an alert dialog that closes only through an action or Cancel. |

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

### ActionMenuTrigger

The button that opens the menu or sheet.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement` | - | Replaces the default button element with your own element, such as a `Button`. |

Also accepts the props of `React.ButtonHTMLAttributes<HTMLButtonElement>`.

### ActionMenuContent

The popup holding the actions; on mobile it adds a Cancel button below them.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cancelLabel` | `string` | `"Cancel"` | Text of the Cancel button shown below the actions on mobile. |

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

### ActionMenuLabel

A non-interactive heading; a menu label on desktop and the sheet title on mobile.

Also accepts the props of `React.HTMLAttributes<HTMLElement>`.

### ActionMenuItem

An actionable entry that closes the menu when chosen.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "destructive"` | - | Use `destructive` to style the item as a destructive action. |
| `disabled` | `boolean` | - | Prevents the item from being chosen. |

Also accepts the props of `React.HTMLAttributes<HTMLElement>`.

### ActionMenuSeparator

A divider between groups of items, rendered on desktop only.

Also accepts the props of `React.HTMLAttributes<HTMLElement>`.

## Accessibility

- On desktop the popup has the `menu` role and each item has the `menuitem` role.
- `ArrowDown` from the trigger opens the menu and focuses the first item; arrow keys move between items and `Enter` runs the focused one.
- `Escape` closes the menu and returns focus to the trigger.

## Notes

Parts carry `data-slot` values `action-menu`, `action-menu-trigger`, `action-menu-content`, `action-menu-label`, `action-menu-item` and `action-menu-separator`. `ActionMenuSeparator` renders nothing on mobile; the sheet groups items in a single card instead.

