# React Action Sheet: iOS-Style, Async and Responsive

> Build a React action sheet like iOS: red destructive rows, a separate Cancel, an awaitable show() that waits for async actions, and a dialog from 768px.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-action-sheet

## TL;DR

A React action sheet is a short list of actions that slides up from the bottom of a phone screen, with a title, destructive rows in red and Cancel in its own card at the bottom. A good one waits for an async action with a spinner in its row, keeps the sheet open and retryable when the action fails, and opens as a compact dialog or an anchored menu on wider screens. The Wingo UI Action Sheet does all of that and adds an awaitable show() that resolves with the chosen action's id, or null on cancel.

A React action sheet is what a phone user expects after tapping ⋯ on a photo, a message or a listing: the page dims, a short list of actions rises from the bottom, Delete is red, and Cancel sits apart where the thumb already is. Most web ports of the iOS action sheet get the slide-up right and stop there. They close before the delete request finishes, show nothing when the request fails, offer no keyboard path, and look like a stretched phone on a 1440px monitor. This guide covers what the pattern needs, then builds it with the Wingo UI [Action Sheet](https://wingo-ui.com/components/action-sheet), including an awaitable `show()` and the dialog or menu it becomes from 768px.

## When should you use an action sheet instead of a menu or an alert?

Use an action sheet when the user just started something and has to choose how it ends, like closing a half-written email: delete the draft or save it. That is the example in Apple's [action sheet guidelines](https://developer.apple.com/design/human-interface-guidelines/action-sheets) (checked October 2026), which also draw the line to its neighbors: an alert "is usually unexpected" and reports a problem, and people expect a menu to appear when they choose to reveal it. The same page asks for three things that shape every implementation:

- **Destructive choices stand out.** Use the destructive style and place those rows at the top, where they get noticed.
- **Cancel goes at the bottom.** It lets people reject an action that might destroy data.
- **No scrolling.** For iOS, Apple says to avoid letting an action sheet scroll: more buttons take longer to choose from, and scrolling invites a stray tap. The watchOS section adds a cap of four buttons including Cancel, a sensible budget on a phone too.

On the web, that becomes a mobile action menu within reach of one thumb, and something calmer on a desktop. Here is how the four related Wingo UI components divide the work:

| Component | Use it for | On a phone | From 768px | Access |
|---|---|---|---|---|
| [Action Sheet](https://wingo-ui.com/components/action-sheet) | A few choices about one action, async rows, `await show()` | iOS cards or a Material sheet | 400px dialog, anchored menu or the same sheet | Pro |
| [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) | Browsing commands, checkbox and radio items, submenus, shortcuts | An action sheet with 48px rows (`responsive`) | A floating menu | Free |
| [Alert Dialog](https://wingo-ui.com/components/alert-dialog) | A yes or no question that needs an explanation | A centered card, buttons stacked full width | A centered card | Pro |
| [Drawer](https://wingo-ui.com/components/drawer) | Any content in a sheet: forms, filters, details | A vaul bottom sheet with snap points | A centered sheet, side panel or dialog | Free |

## What does the Action Sheet render on a phone?

On a phone it renders a bottom sheet in one of two styles. `variant="ios"` (the default) floats a card with the title, the message and 56px centered rows 8px above the home indicator, with Cancel in its own card underneath. `variant="material"` is one attached sheet with a handle, 48px rows with icons at the start, and Cancel as the last row. Both drag down to dismiss, because the phone sheet is the free [Drawer](https://wingo-ui.com/components/drawer), built on vaul.

Each row is an `ActionSheetAction`: `{ id, label, icon?, description?, tone?, disabled?, onSelect?, keepOpen?, badge? }`, where `tone` is `"neutral"`, `"brand"` or `"danger"`. Here is a declarative react action sheet component on a listing card, with the destructive row first as Apple recommends:

```tsx
"use client";

import { Link2, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { ActionSheet } from "@/components/ui/action-sheet";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/toast";

type Listing = { id: string; title: string; posted: string };

export function ListingActions({
  listing,
  onEdit,
  onDeleted,
}: {
  listing: Listing;
  onEdit: (id: string) => void;
  onDeleted: (id: string) => void;
}) {
  return (
    <ActionSheet
      title={listing.title}
      message={listing.posted}
      trigger={
        <Button variant="ghost" size="icon" radius="full" aria-label={`Actions for ${listing.title}`}>
          <MoreHorizontal />
        </Button>
      }
      actions={[
        {
          id: "delete",
          label: "Delete listing",
          icon: <Trash2 />,
          tone: "danger",
          // the sheet waits with a spinner in this row until the promise settles
          onSelect: async () => {
            const res = await fetch(`/api/listings/${listing.id}`, { method: "DELETE" });
            if (!res.ok) {
              toast.error("Could not delete the listing. Try again.");
              // rethrow so the row shakes and stays ready for a retry
              throw new Error(`DELETE failed with ${res.status}`);
            }
            onDeleted(listing.id);
          },
        },
        { id: "edit", label: "Edit listing", icon: <Pencil />, onSelect: () => onEdit(listing.id) },
        {
          id: "copy",
          label: "Copy link",
          icon: <Link2 />,
          // void: the sheet closes at once instead of waiting on the clipboard
          onSelect: () => void navigator.clipboard.writeText(`${location.origin}/listings/${listing.id}`),
        },
      ]}
    />
  );
}
```

`trigger` renders with `asChild`, so pass a single element that takes a ref, like a `Button`. Icons stay hidden in the iOS sheet unless you set `showIcons`, and they show by default in Material and on desktop. For several groups, pass `groups` (an array of action arrays) instead of `actions`: on iOS each group becomes its own card.

> Live demo (Action Sheet): Tap ⋯ on the listing to open the iOS sheet, then try Delete listing: its row spins for a moment while the other rows dim, and the sheet closes when the promise resolves. Try it at [Action Sheet](https://wingo-ui.com/components/action-sheet) and install it with `npx wingo-ui@latest add action-sheet`.

## How does the sheet handle async and destructive actions?

If `onSelect` returns a promise, the row shows a spinner, the other rows dim, and the sheet refuses to close: Escape, a tap on the dimmed page and the drag all wait until the promise settles. When it resolves, `onAction(id)` fires and the sheet closes. When it rejects, the row shakes like a wrong passcode and stays ready for a retry. That is the difference between a sheet that says "Deleted" and one that only looks like it did.

The shake tells the user that something failed, not what. That is why the listing example calls `toast.error()` before it rethrows: the [Toast](https://wingo-ui.com/components/toast) says why, the shake says where, and the row is ready for a second tap. The toast needs a `Toaster` mounted once in your layout. Two props change the closing behavior: `closeOnAction={false}` keeps the sheet open after every action, and `keepOpen: true` on a single row does the same for that row only, which suits a toggle like "Mute notifications".

For destructive action confirmation, the sheet itself is often the right step. A title such as "Delete this listing?", a message under it, one red row and Cancel is a two-tap confirm that sits where the thumb is. When the first enabled action has the danger tone, the sheet puts keyboard focus on Cancel instead of on Delete, so pressing Enter right after it opens never destroys anything.

## How do you await an action sheet with show()?

Call `useActionSheet().show(options)` from any event handler and `await` it: you get the chosen action's id back, or `null`. Mount `ActionSheetProvider` once near the root first. `show()` takes the same props as `ActionSheet` minus `open`, `defaultOpen`, `onOpenChange` and `trigger`, and resolves `null` when the user cancels or dismisses. It resolves after the chosen row's promise resolves, so the code after the `await` runs once the work is done. This mirrors Apple's Mail example, the draft you are closing:

```tsx
"use client";

import { useActionSheet } from "@/components/ui/action-sheet";
import { Button } from "@/components/ui/button";

export function CloseDraftButton({
  saveDraft,
  discardDraft,
  onClose,
}: {
  saveDraft: () => Promise<void>;
  discardDraft: () => Promise<void>;
  onClose: () => void;
}) {
  const actionSheet = useActionSheet();

  const close = async () => {
    const choice = await actionSheet.show({
      actions: [
        { id: "discard", label: "Delete draft", tone: "danger", onSelect: discardDraft },
        { id: "save", label: "Save draft", onSelect: saveDraft },
      ],
    });
    // null means Cancel, Escape or a dismiss: keep editing
    if (choice) onClose();
  };

  return (
    <Button variant="ghost" onClick={close}>
      Cancel
    </Button>
  );
}
```

```tsx
// app/layout.tsx
import type { ReactNode } from "react";
import { ActionSheetProvider } from "@/components/ui/action-sheet";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ActionSheetProvider defaults={{ cancelLabel: "Cancel" }}>{children}</ActionSheetProvider>
      </body>
    </html>
  );
}
```

`defaults` sits under every call's own options, which is where translated labels or a house `variant` go. A second `show()` replaces the open sheet and the first call resolves `null`. When the sheet closes, focus returns to whatever had it before the call, and `useActionSheet()` throws outside the provider so a missing provider fails loudly in development. A rejected row keeps the promise pending, since the user can still retry or cancel.

## What does the action sheet look like on desktop?

Phones always get the sheet; at 768px and up, the `desktop` prop picks one of three layouts:

- `desktop="dialog"` (the default): a compact 400px dialog with the same rows, start-aligned with icons.
- `desktop="popover"`: a menu anchored to the trigger. It shows no Cancel, because menus close on Escape and outside clicks. A `show()` call has no trigger to hang from, so it falls back to the dialog.
- `desktop="sheet"`: the phone sheet at every width, for kiosk or tablet-first screens.

```tsx
<ActionSheet desktop="popover" title="Quote #Q-2026-118" actions={actions} trigger={moreButton} />
```

Framework7 does something similar: its [action sheet docs](https://framework7.io/docs/action-sheet) list a `convertToPopover` option that turns the sheet into a popover on large screens. Ionic's [`ion-action-sheet`](https://ionicframework.com/docs/api/action-sheet) (`@ionic/react` 9.0.8, both checked October 2026) takes `destructive` and `cancel` button roles and always renders the cancel button last, but its docs describe no separate wide-screen layout, and `present()` resolves with nothing: you read the choice from the `onDidDismiss` result. If your actions are really a command menu with checkboxes, submenus and shortcuts, start from the [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) instead: it is a floating menu on desktop and becomes an action sheet below 768px on its own.

> Live demo (Dropdown Menu): Open the menu on desktop to see a floating menu with icons and shortcuts; on a phone the same items slide up as an action sheet with a Cancel button. Try it at [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) and install it with `npx wingo-ui@latest add dropdown-menu`.

## Is the action sheet accessible with a keyboard?

Yes, and it follows the same rules as a menu. Focus lands on the first action when it opens, or 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 row, Tab cycles inside, and Escape cancels. The title labels the sheet; without one, `labels.sheetTitle` ("Actions" by default) becomes a hidden accessible title, and `message` becomes the description. With reduced motion, the phone sheet's slide is cut short, the desktop dialog only fades, and failed rows do not shake.

## Which action sheet pitfalls show up on real phones?

Four show up as soon as the sheet meets real requests and real devices:

- **Returning a promise you do not want to wait for.** Every promise from `onSelect` holds the sheet open. `navigator.share()` is the classic case: it settles only when the user finishes with the native share sheet, and rejects when they cancel it, which shakes your row. Call it with `void` or close first.
- **Too many rows.** Every extra row is one more thing to read before the thumb moves, which is why Apple asks for a sheet that never scrolls. The sheet caps its list at `60dvh` and scrolls inside its card with Cancel pinned, but a long list of people or folders belongs in a picker. Our guide to the [React bottom sheet for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) covers sheets with real content.
- **The home indicator.** A sheet glued to `bottom: 0` puts Cancel under the iPhone's home bar. The iOS cards float above it and the Material sheet pads with `env(safe-area-inset-bottom)`; the [safe area insets guide](https://wingo-ui.com/blog/tailwind-safe-area-insets) explains the CSS.
- **Using the sheet for irreversible, explained deletes.** "Delete account and every invoice" deserves an [Alert Dialog](https://wingo-ui.com/components/alert-dialog): a centered card where a tap on the dimmed page does nothing, focus starts on Cancel, and `await confirm()` resolves `true` or `false`.

> Live demo (Alert Dialog): Press Delete to open the alert dialog: focus starts on Cancel, the dimmed page ignores clicks, and the confirm button spins while the request runs. Try it at [Alert Dialog](https://wingo-ui.com/components/alert-dialog) and install it with `npx wingo-ui@latest add alert-dialog`.

The Action Sheet is one of the small details covered in our list of what makes a [web app that feels native](https://wingo-ui.com/blog/web-app-that-feels-native). For the whole mobile layer, from touch targets to bottom sheets, read the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components) or browse [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## How do you install the Action Sheet?

Run `npx wingo-ui@latest add action-sheet`. It copies `components/ui/action-sheet.tsx` with the Button, Dialog, Drawer, Popover and Spinner it imports, the shared hooks, and their npm packages (`motion`, `lucide-react`, `vaul` and the Radix primitives). The Action Sheet is part of [Wingo UI Pro](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`; the Drawer and Dropdown Menu it sits next to are free.

## Components in this post

- [Action Sheet](https://wingo-ui.com/components/action-sheet) (Pro): A React iOS-style action sheet: a short list of actions with a title, red destructive rows, a separate Cancel and an awaitable show(). Install: `npx wingo-ui@latest add action-sheet`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) (Free): A React dropdown menu: icons, descriptions, shortcuts, badges, checkbox and radio items, submenus, and an action sheet on phones. Install: `npx wingo-ui@latest add dropdown-menu`
- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) (Pro): A React alert dialog for confirmations: an async confirm that loads, fails inline and shakes, plus an await confirm() API. Install: `npx wingo-ui@latest add alert-dialog`

## FAQ

### What is an action sheet in React?

An action sheet is a modal list of choices tied to something the user just did, such as tapping a ⋯ button on a photo. It slides up from the bottom on a phone, shows destructive choices in red and ends with a Cancel button. In React it is a bottom sheet with a list of real buttons inside; the Wingo UI version builds its phone sheet on vaul through the Drawer.

### What is the difference between an action sheet and a dropdown menu?

A dropdown menu is something people open on purpose to browse commands, and it can hold checkboxes, radio items and submenus. An action sheet offers a few choices about one action, with a title and a Cancel. On phones the Wingo UI Dropdown Menu already turns into an action sheet below 768px, so pick the Action Sheet when you want async rows, groups or an awaitable call.

### Should I use an action sheet or an alert dialog to confirm a delete?

Apple's guidelines say to use an action sheet, not an alert, for choices related to an action the user started, and describe alerts as usually unexpected news about a problem. A sheet with one red row and Cancel is enough for most deletes; use an alert dialog when the delete cannot be undone and needs a sentence of explanation or a typed confirmation.

### How many buttons should an action sheet have?

Few enough that the sheet never scrolls: Apple's iOS guidance says to avoid a scrolling action sheet, and its watchOS guidance caps it at four buttons including Cancel. When you pass more rows, the Wingo UI sheet caps its list at 60% of the dynamic viewport height and scrolls inside its card, with Cancel pinned below.

### Is the Wingo UI Action Sheet free?

No. The Action Sheet and the Alert Dialog are Pro components. The Drawer, the Dialog, the Popover and the Dropdown Menu it is built from or sits next to are free.

---

Source: https://wingo-ui.com/blog/react-action-sheet
