
2-bedroom apartment, Brooklyn
Park Slope · 780 sq ft · floor 3 of 4
$2,450/mo
Mobile
Action Sheet
v1.0.0Added in 1.0.0
A React iOS-style action sheet: a short list of actions with a title, red destructive rows, a separate Cancel and an awaitable show().
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.
Action Sheet is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add action-sheet, together with Button, Dialog, Drawer, Popover, Spinner and 5 more and the npm packages motion and lucide-react.
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add action-sheetAction Sheet is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Import ActionSheet from @/components/ui/action-sheet.
"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>
)
}titledefault ""ReactNodeWhat 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).
messagedefault ""ReactNodeA short explanation under the title; it becomes the sheet's accessible description.
actionsActionSheetAction[]{ 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.
groupsActionSheetAction[][]Several groups: separate cards on iOS, separated blocks in material and on desktop. Replaces actions.
cancelLabeldefault "Cancel"stringThe Cancel button's text (wins over labels.cancel).
labelsPartial<ActionSheetLabels>{ cancel, sheetTitle }, merged over the English defaults ("Cancel", "Actions"; see Parts). sheetTitle is the hidden accessible title while title is empty.
triggerReactNodeThe element that opens it, rendered with asChild (a single element that takes a ref, e.g. a Button).
variantdefault "ios""ios" | "material"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.
aligndefault "auto""auto" | "center" | "start"Row text alignment. auto centers the iOS sheet's rows and starts material and desktop rows (icons at the start).
showIconsdefault falsebooleanShows 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.
showCanceldefault truebooleanThe Cancel button. Without it the sheet closes by drag, a tap on the dimmed page or Escape. The anchored menu never shows one.
classNamesPartial<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).
defaultOpendefault falsebooleanStarts open (uncontrolled). Pass open and onOpenChange instead to control it.
openbooleanControlled open state; pair it with onOpenChange.
onOpenChange(open: boolean) => voidCalled when it opens or closes. Closes are refused while an action's promise is running.
desktopdefault "dialog""dialog" | "popover" | "sheet"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.
containerHTMLElement | nullPortal target; document.body by default.
closeOnActiondefault truebooleanCloses 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) => voidFires for every chosen action with its id, after its onSelect (after the promise resolves).
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.
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.
defaultsActionSheetOptionsOptions under every show() call's own: translated labels, a house variant, a container.
childrenReactNodeYour app.
The English labels: { cancel: "Cancel", sheetTitle: "Actions" }, the hidden title used while title is empty.
One row: { id, label, icon?, description?, tone? ("neutral" | "brand" | "danger"), disabled?, onSelect?, keepOpen?, badge? }; a promise from onSelect shows a spinner until it settles.
await actionSheet.show() from a long press on a chat photo; resolves with the id or null.
One attached sheet with icons, descriptions, a disabled row and Cancel as the last row.
Groups become separate iOS cards; the destructive group sits last.
A promise from onSelect keeps the sheet open with a row spinner; a rejection shakes the row.
desktop="popover" hangs the actions under the toolbar's ⋯ button like a menu.
Eight rows with avatars and descriptions; the current owner is disabled.
A title, a message and one danger action: a lighter alternative to an alert dialog on phones.
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
Run npx wingo-ui@latest add action-sheet. It also adds the Wingo UI items it builds on (Button, Dialog, Drawer, Popover, Spinner and 5 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/action-sheet.
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).
Comparisons and alternatives10 min read
Mobile UI in React9 min read
Mobile UI in React11 min read
Mobile UI in React9 min read

2-bedroom apartment, Brooklyn
Park Slope · 780 sq ft · floor 3 of 4
$2,450/mo
Tap ⋯ on the listing. Resize below 768px (or open the phone frame) for the iOS sheet.
400px dialog · auto-aligned · auto icons · cancel “Cancel” · closes on action