# Action Sheet

> A React iOS-style action sheet: a short list of actions with a title, red destructive rows, a separate Cancel and an awaitable show().

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add action-sheet`
- Docs: https://wingo-ui.com/components/action-sheet

## Interaction

Tap the trigger and, on a phone, the page dims while the sheet slides up from the bottom: the title card with its actions floats 8px above the home indicator and Cancel sits in its own card under it (material: one attached sheet with a handle, icon rows and Cancel as the last row). Tap an action to run it and close the sheet; tap Cancel or the dimmed page, or drag the sheet down, to dismiss. An action that returns a promise shows a spinner in its row while the other rows dim and wait; the sheet closes when it resolves, and when it fails the row shakes and stays ready to retry. From 768px it opens as a compact dialog (or a menu anchored to the trigger with desktop="popover", or the same sheet with desktop="sheet"). Keyboard: focus lands on the first action (on Cancel when the first action is destructive), Up and Down move between the actions and Cancel, Home and End jump to the ends, Enter or Space runs the focused one, Tab cycles inside, Escape cancels and focus returns to the trigger, or for show() to whatever had it before. With reduced motion the sheet and dialog fade and nothing shakes.

## Installation

```bash
npx wingo-ui@latest add action-sheet
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/action-sheet` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/action-sheet.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
"use client"

import { MoreHorizontal, Pencil, Share, Trash2 } from "lucide-react"
import { ActionSheet, ActionSheetProvider, useActionSheet } from "@/components/ui/action-sheet"
import { Button } from "@/components/ui/button"

// declarative: pass a trigger and the actions
export function ListingMenu() {
  return (
    <ActionSheet
      title="2-bedroom apartment, Brooklyn"
      cancelLabel="Cancel"
      trigger={
        <Button variant="ghost" size="icon" radius="full" aria-label="Listing actions">
          <MoreHorizontal />
        </Button>
      }
      actions={[
        { id: "edit", label: "Edit listing", icon: <Pencil /> },
        { id: "promote", label: "Promote", tone: "brand", badge: "from $19" },
        { id: "share", label: "Share", icon: <Share /> },
        {
          id: "delete",
          label: "Delete listing",
          icon: <Trash2 />,
          tone: "danger",
          onSelect: () => new Promise((resolve) => setTimeout(resolve, 1200)),
        },
      ]}
      onAction={(id) => console.log(id)}
    />
  )
}

// imperative: useActionSheet().show(options) opens the provider's sheet and resolves with the
// chosen action's id, or null on cancel or dismiss. options are the ActionSheet props without
// open / defaultOpen / onOpenChange / trigger. a second show() replaces the open sheet (the first
// resolves null), focus returns to whatever had it, and the hook throws without a provider.
function PhotoMenu() {
  const actionSheet = useActionSheet()
  const open = async () => {
    const id = await actionSheet.show({
      actions: [
        { id: "save", label: "Save to photos" },
        { id: "delete", label: "Delete for me", tone: "danger" },
      ],
    })
    if (id) console.log(id)
  }
  return <Button onClick={open}>Photo options</Button>
}

// mount ActionSheetProvider once near the root; its defaults prop (ActionSheetOptions) sits
// under every show() call's own options, e.g. translated labels or a house variant
export function App() {
  return (
    <ActionSheetProvider defaults={{ cancelLabel: "Cancel" }}>
      <ListingMenu />
      <PhotoMenu />
    </ActionSheetProvider>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title?` | `ReactNode` | `""` | What the actions are about, e.g. the listing's name. Small and gray in the iOS card, a heading in material and the dialog, a group label in the menu. Without one the sheet still has a hidden title (labels.sheetTitle). |
| `message?` | `ReactNode` | `""` | A short explanation under the title; it becomes the sheet's accessible description. |
| `actions?` | `ActionSheetAction[]` |  | { id, label, icon?, description?, tone?: "neutral" \| "brand" \| "danger", disabled?, onSelect?: () => void \| Promise<unknown>, keepOpen?, badge? }. A promise from onSelect keeps the sheet open with a spinner in the row until it settles; a rejection shakes the row. keepOpen runs it without closing. |
| `groups?` | `ActionSheetAction[][]` |  | Several groups: separate cards on iOS, separated blocks in material and on desktop. Replaces actions. |
| `cancelLabel?` | `string` | `"Cancel"` | The Cancel button's text (wins over labels.cancel). |
| `labels?` | `Partial<ActionSheetLabels>` |  | { cancel, sheetTitle }, merged over the English defaults ("Cancel", "Actions"; see Parts). sheetTitle is the hidden accessible title while title is empty. |
| `trigger?` | `ReactNode` |  | The element that opens it, rendered with asChild (a single element that takes a ref, e.g. a Button). |
| `variant?` | `"ios" \| "material"` | `"ios"` | ios: floating cards with 56px centered rows and a separate Cancel card. material: one attached sheet with a handle, 48px rows with icons at the start and Cancel as the last row. |
| `align?` | `"auto" \| "center" \| "start"` | `"auto"` | Row text alignment. auto centers the iOS sheet's rows and starts material and desktop rows (icons at the start). |
| `showIcons?` | `boolean` | `false` | Shows each action's icon at the row's start. When unset: off for the iOS sheet, on for material and on desktop; true forces them on, false off. |
| `showCancel?` | `boolean` | `true` | The Cancel button. Without it the sheet closes by drag, a tap on the dimmed page or Escape. The anchored menu never shows one. |
| `classNames?` | `Partial<Record<ActionSheetSlot, string>>` |  | Classes for the parts: overlay, content, header, title, message, group, action, icon, label, description, badge, spinner, cancel. className goes on the root (action-sheet). |
| `defaultOpen?` | `boolean` | `false` | Starts open (uncontrolled). Pass open and onOpenChange instead to control it. |
| `open?` | `boolean` |  | Controlled open state; pair it with onOpenChange. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when it opens or closes. Closes are refused while an action's promise is running. |
| `desktop?` | `"dialog" \| "popover" \| "sheet"` | `"dialog"` | What renders at 768px and up: a compact 400px dialog, a menu anchored to the trigger, or the phone sheet. Phones always get the sheet; without a trigger popover falls back to the dialog. |
| `container?` | `HTMLElement \| null` |  | Portal target; document.body by default. |
| `closeOnAction?` | `boolean` | `true` | Closes after an action runs (after its promise resolves). Off: the sheet stays open until Cancel or a dismiss; an action can also opt out with keepOpen. |
| `onAction?` | `(id: string) => void` |  | Fires for every chosen action with its id, after its onSelect (after the promise resolves). |

## Parts

### useActionSheet (hook)

Returns { show, close }. show(options) opens the provider's sheet with the ActionSheet props (minus open, defaultOpen, onOpenChange and trigger) and resolves with the chosen action's id, or null on cancel. Throws outside ActionSheetProvider.

### ActionSheetProvider (component)

Mount it once near the root to enable useActionSheet; it renders one sheet for every show() call, and a second show() replaces the open one.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaults?` | `ActionSheetOptions` |  | Options under every show() call's own: translated labels, a house variant, a container. |
| `children?` | `ReactNode` |  | Your app. |

### DEFAULT_ACTION_SHEET_LABELS (constant)

The English labels: { cancel: "Cancel", sheetTitle: "Actions" }, the hidden title used while title is empty.

### ActionSheetAction (type)

One row: { id, label, icon?, description?, tone? ("neutral" | "brand" | "danger"), disabled?, onSelect?, keepOpen?, badge? }; a promise from onSelect shows a spinner until it settles.

## Examples

- **Imperative show()**: await actionSheet.show() from a long press on a chat photo; resolves with the id or null.
- **Material**: One attached sheet with icons, descriptions, a disabled row and Cancel as the last row.
- **Grouped**: Groups become separate iOS cards; the destructive group sits last.
- **Async actions**: A promise from onSelect keeps the sheet open with a row spinner; a rejection shakes the row.
- **Anchored on desktop**: desktop="popover" hangs the actions under the toolbar's ⋯ button like a menu.
- **Many actions**: Eight rows with avatars and descriptions; the current owner is disabled.
- **Confirm a deletion**: A title, a message and one danger action: a lighter alternative to an alert dialog on phones.

## See also

- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Context Menu](https://wingo-ui.com/components/context-menu) ([Markdown](https://wingo-ui.com/components/context-menu.md))

---

Canonical: https://wingo-ui.com/components/action-sheet
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
