Action Sheet
A mobile-first bottom sheet presenting a set of contextual actions. Built on the Base UI Drawer primitive with swipe-to-dismiss. Compound component pattern with Header, Body, Group, Item, Footer and Cancel. List, grid and rail layouts. Five heights, five roundings, three scrim modes, single and multi selection, LTR and RTL support.
pnpm add @tessinaui/uiWhen to use
- A short list of actions that follow from something the person just did. A "more" button, a long-press or a share affordance, on phones and narrow viewports.
- A choice that includes a destructive action. Mark the row
intent="error"and keep the detachedActionSheetCancelbelow it. - Rows that need more than a menu gives. Icons, descriptions, a
selectionstate or aloadingrow fit;layout="grid"and"rail"suit tiles. - Labels that are verbs. "Delete photo", not "Photo deletion"; the row truncates rather than wrapping, so keep it short.
- Kept short. Around 12 rows is the ceiling, and a sheet should not scroll at all.
When not to use
- Where a pointer and room to anchor exist. Use DropdownMenu: shortcuts, cascading submenus and hover-open belong there. See Responsive for the swap.
- When the person must read or fill something in first. Use Modal; for a confirmation of a destructive step, AlertDialog.
- For a panel with its own workflow (filters, forms, inspectors). Use Drawer.
- For a list that scrolls far. It is a Drawer or a page.
- For small anchored content that should not dim the page. Use Popover.
- More often than needed. A sheet interrupts the task; prefer an inline or undoable action first.
Installation
pnpm add @tessinaui/uiUsage
import {
ActionSheet,
ActionSheetTrigger,
ActionSheetContent,
ActionSheetHeader,
ActionSheetTitle,
ActionSheetDescription,
ActionSheetBody,
ActionSheetGroup,
ActionSheetSeparator,
ActionSheetItem,
ActionSheetFooter,
ActionSheetCancel,
} from "@tessinaui/ui";<ActionSheet>
<ActionSheetTrigger className="...">Open</ActionSheetTrigger>
<ActionSheetContent>
<ActionSheetHeader>
<ActionSheetTitle>Actions</ActionSheetTitle>
<ActionSheetDescription>Choose an action below.</ActionSheetDescription>
</ActionSheetHeader>
<ActionSheetGroup>
<ActionSheetItem icon={<Share2 />}>Share</ActionSheetItem>
<ActionSheetItem icon={<Copy />}>Copy link</ActionSheetItem>
<ActionSheetSeparator />
<ActionSheetItem intent="error" icon={<Trash2 />}>Delete</ActionSheetItem>
</ActionSheetGroup>
<ActionSheetCancel />
</ActionSheetContent>
</ActionSheet>Examples
Default
A trigger button that opens a bottom sheet with a group of actions and a cancel row.
With Header
Add an ActionSheetHeader with a title and description above the actions. Pass close
for the trailing dismiss button.
Destructive Actions
Mark dangerous actions with intent="error". In a list sheet they go last, separated
by a rule, so a thumb travelling down the list never lands on one by momentum. (In a
confirmation sheet the destructive button goes on top instead; see
Confirmation.)
Post options
The full row vocabulary: leading icons, a trailing chevron, a trailing badge, a link row
that keeps its link semantics through render, and destructive actions below a rule.
onAction on the root reports each row's value in one handler.
Sizes
Five height budgets — sm (40dvh), md (60dvh), lg (75dvh), xl (90dvh) and full
(the whole viewport) — the same ramp as Drawer's bottom side. A full sheet always needs
a close affordance, since there is no scrim strip left to tap.
Rounded
Five corner-rounding options via the rounded prop. The value cascades to the handle,
tile plates and skeleton.
Curving your own content
Content you put inside — an avatar or preview tile in a row — should curve like the chrome around it, or it reads as pasted on. The sheet publishes its inner radius as a CSS variable:
<img className="rounded-[var(--action-sheet-item-radius)]" />It tracks whatever the component is actually doing: the explicit step when rounded is set (6px at md), and 0 at rounded="none" — so your content squares off exactly when the container does.
Inset and edge-to-edge
inset (the default) floats the sheet off the viewport edges, the iOS-classic card.
inset={false} attaches it edge-to-edge with only the top corners rounded — the majority
form in the wild, and the one to prefer at compact widths, where inset margins cost row
width the labels need.
Scrim
blur frosts and dims the page, dim is a plain dark overlay, and shadow keeps the
page fully visible so the panel separates through elevation alone. Clicks outside still
dismiss in every mode.
Non-modal
modal={false} renders no scrim, locks nothing and leaves the page interactive.
modal="trap-focus" keeps focus inside the sheet while the page stays scrollable.
Header actions
The header takes a leading back slot, a trailing action slot and a close button.
With any slot present it becomes a three-column title row; with none it stays a centered
iOS-style block.
Groups
detached gives a group its own card below the sheet (the iOS grouped shape), dividers
draws a hairline between every row, variant="inset" recesses the group into a well, and
label gives it a heading that also names it for assistive tech. Inside an inset well
the muted inks step up a notch so labels, descriptions and the destructive row keep
4.5:1 over the tint.
Share
layout="rail" scrolls targets horizontally and layout="grid" wraps them into icon
tiles — the two share-sheet shapes. ActionSheetBody holds free content such as a link
field.
Attach
Tiles above a list, two-line rows, and a toggle row. The toggle row is itself the switch
(role="switch" with aria-checked) and paints its state with the decorative
SwitchVisual in the trailing slot, so no control nests inside another. It sets
closeOnSelect={false} so the sheet stays open.
Account switcher
The rows form one radio group: a Radio in the trailing slot carries the state and the
arrow-key model, and each row renders as a plain element (render={<div />}) so no
control nests inside another. The leading slot sizes icons but never media, so an
Avatar keeps its own box.
Sort
A single-select group with an ActionSheetFooter. Rows set closeOnSelect={false} so the
choice is applied by the footer button rather than on every tap.
Collections
selection="multiple" marks rows with aria-pressed and keeps the sheet open by default.
A Search field in ActionSheetBody filters the list.
Confirmation
A title, a description and an ActionSheetFooter of buttons. Stacked, the affirmative
action goes first in the DOM and on top, so reading order, focus order and visual
order agree. Side by side, dismiss goes left and the affirmative right.
Photo source
align="center" centers row labels for the stock-iOS look, the preferred action goes
first, and detached={false} turns Cancel into an ordinary last row instead of its own card.
Nested navigation
Drill-down panes inside one sheet: rows with closeOnSelect={false} swap the content and
ActionSheetBack returns.
Responsive
One action list, two containers — a bottom sheet on phones and an anchored dropdown menu
from md up. Both are rendered and swapped by CSS, so there is no hydration mismatch.
Loading
Use ActionSheetSkeleton as a placeholder while sheet content loads. It takes the same
size, rounded and inset props as the sheet and reads the same row geometry.
On-color
On a coloured Surface the trigger takes the plate's ink and its focus ring from context — no prop needed. The sheet stays a standard panel: a portal opens it above the plate rather than on it, so its title, description, rows and the destructive action read exactly as they do on the page.
RTL
dir="rtl" on ActionSheet is stamped on the portalled panel: row icons and labels start from the right, while
trailing clusters and the header's close button move to the left. The sheet still rises from the bottom. Leave dir
unset and the panel takes the direction around its trigger, or the document's when it opens without one. Its close button keeps its English name unless dir="rtl" is set or strings overrides it.
API Reference
ActionSheet
Root component. Provides context and wraps the Base UI Drawer.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Height budget (40dvh / 60dvh / 75dvh / 90dvh / full viewport) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius; cascades to the handle, tile plates and skeleton |
inset | boolean | true | Float the sheet off the viewport edges, or attach it edge-to-edge |
dir | "ltr" | "rtl" | — | Text direction applied to the panel and all children. Unset, it is read around the trigger |
strings | Partial<ActionSheetStrings> | English, Arabic under dir="rtl" | Override the header close button's name (close) |
open | boolean | — | Controlled open state |
defaultOpen | boolean | — | Uncontrolled initial open state |
onOpenChange | (open: boolean, event: Event) => void | — | Called when the open state changes |
onOpenChangeComplete | (open: boolean) => void | — | Called after the open/close animation settles |
onAction | (value: string, event: React.MouseEvent) => void | — | Called with an item's value when it is activated, before the sheet closes |
dismissible | boolean | true | Whether scrim tap, Escape and the swipe close the sheet |
modal | boolean | "trap-focus" | true | true traps focus, locks scroll and shows the scrim; "trap-focus" traps focus only; false is fully non-modal |
scrim | "blur" | "dim" | "shadow" | "blur" | Background treatment while open (modal only) |
ActionSheetContent
Renders the panel anchored to the bottom of the viewport inside a portal, with the
backdrop when the sheet is modal. ActionSheetCancel and any ActionSheetGroup detached
child are hoisted out of the main card automatically, fragments included.
Base UI's initialFocus and finalFocus pass through: use initialFocus to name the
element that takes focus on open, and finalFocus to redirect focus on close.
| Prop | Type | Default | Description |
|---|---|---|---|
size | ActionSheetSize | — | Overrides the height budget from the root |
rounded | ActionSheetRounded | — | Overrides the corner radius from the root |
inset | boolean | — | Overrides the inset presentation from the root |
showHandle | boolean | true | Whether to show the drag handle pill |
aria-label | string | — | Required when the sheet has no ActionSheetHeader |
ActionSheetHeader
Optional header with title and description. With no slots it is a centered block; give it
back, action or close and it becomes a three-column title row.
| Prop | Type | Default | Description |
|---|---|---|---|
close | boolean | ReactNode | — | true renders the house close button; a node supplies your own |
action | ReactNode | — | Trailing text action ("Edit", "Done", "Select all") |
back | ReactNode | — | Leading slot, for ActionSheetBack |
divider | boolean | true | Hairline under the header |
ActionSheetGroup
Container for action rows or tiles.
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "list" | "grid" | "rail" | "list" | Rows, a wrapping tile grid, or a horizontal tile rail |
columns | number | 4 | Columns when layout="grid" |
variant | "plain" | "inset" | "cards" | "plain" | On the card, in a recessed well, or one cell per row |
label | ReactNode | — | Heading above the group; also names it for assistive tech |
dividers | boolean | false | Hairline between every row |
detached | boolean | false | Render as its own card below the sheet |
selection | "none" | "single" | "multiple" | "none" | Row selection semantics |
align | "start" | "center" | "start" | Label alignment inside rows |
ActionSheetItem
Individual action row, or a tile inside a grid or rail group.
| Prop | Type | Default | Description |
|---|---|---|---|
intent | "none" | "error" | "none" | error is the destructive row |
variant | "default" | "strong" | "default" | Deprecated — no longer renders anything. strong used to bold the preferred action's label; control text is now one weight library-wide, so the row paints identically either way. Still accepted so callers do not break — mark a preferred action with intent or ordering instead |
size | "sm" | "md" | "lg" | "xl" | — | Overrides the row density from the sheet |
icon | ReactNode | — | Leading icon, avatar or thumbnail (decorative) |
trailing | ReactNode | — | Trailing chevron, value text, badge or shortcut (decorative, never interactive) |
description | ReactNode | — | Second line under the label |
selected | boolean | false | Marks the current choice inside a selection group |
loading | boolean | false | Shows a spinner and marks the row busy: it stays focusable but refuses activation, so the sheet stays open |
closeOnSelect | boolean | true | Close the sheet when the row activates (false in selection="multiple" groups) |
value | string | — | Reported to the root's onAction |
render | ReactElement | — | Swap in a custom element — an <a> for navigation rows |
disabled | boolean | — | Disables the row. A render link, which ignores disabled, takes aria-disabled and leaves the tab order |
intent="destructive" and intent="default" still work as deprecated aliases of error
and none, and will be removed in a future minor.
ActionSheetFooter
Action area at the bottom of the sheet. Put house Buttons inside.
| Prop | Type | Default | Description |
|---|---|---|---|
stacked | boolean | stacked below sm, row above | Full-width stacked actions, or a row |
divider | boolean | false | Hairline above the footer |
ActionSheetCancel
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | — | Overrides the size from context |
show | boolean | true | false renders nothing |
detached | boolean | true | Its own card below the sheet, or an ordinary last row |
children | ReactNode | "Cancel" | Button label |
ActionSheetBack
Leading back button for drill-down sheets. Renders an icon only, so aria-label is
required. The arrow flips automatically in RTL.
ActionSheetBody
Padded, scrollable region for free content — fields, chips, media, a paragraph. Use
ActionSheetGroup for action rows.
ActionSheetSeparator
Horizontal rule between rows, inset to the label start.
ActionSheetTrigger · ActionSheetClose
Buttons that open and close the sheet. Style via className or swap the element with
render.
ActionSheetSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size · rounded · inset | — | "md" · "md" · true | Mirror the live sheet |
showHandle | boolean | true | Render the handle |
showHeader | boolean | false | Render the header bars |
items | number | 4 | Number of row placeholders |
showCancel | boolean | true | Render the cancel card |
label | string | null | "Loading", Arabic under dir="rtl" | Announced while shown; null stays silent |
Behaviour
- Slides up from the bottom edge on a 450ms deceleration curve. Releasing a swipe scales the exit duration by the flick velocity, so a hard flick lands fast.
- Swipe down to dismiss. Touch swipes work anywhere on the panel and respect scrollable content (the gesture engages at the scroll edge); mouse drags work from the handle and panel chrome, so text inside the sheet stays selectable.
- Closes on scrim tap,
Escape, the swipe,ActionSheetCancel, and anyActionSheetItemwithcloseOnSelect(the default).dismissible={false}blocks the first three and keeps the rest. - Height is constrained by
size; the group scrolls internally when content overflows. When the header and footer alone ask for more than the budget, the whole inner column scrolls instead, so no action is ever cut off with nothing to scroll. - On a viewport 30rem tall or less (a phone in landscape, or the page zoomed to 400%) the sheet lifts its height cap to the viewport and scrolls as one column, cancel card and detached groups included. Above 30rem nothing changes.
- The panel is capped at 480px and centered on viewports wider than that.
- While a software keyboard is up the sheet grows by the keyboard's height and pads its
bottom by the same amount, so a field in
ActionSheetBody, the rows under it and the footer stay above the keyboard. It returns to its size when the keyboard is dismissed.
Accessibility
- The panel is a Base UI drawer dialog:
role="dialog", labelled byActionSheetTitleand described byActionSheetDescription. A sheet with no header must passaria-labelonActionSheetContent— development builds warn when neither is present. - Rows follow the APG modal-dialog pattern, not the menu pattern: Tab and Shift+Tab
move between rows and wrap, Enter and Space activate, Escape closes. Rows are buttons,
not
menuitems, so no arrow-key roving is claimed. Mixing the two would be a violation of both patterns. - Focus moves into the panel on open and returns to the trigger on close. Nothing
auto-focuses a destructive row; pass
initialFocusonActionSheetContentto name a safer target explicitly. selection="single"marks the current row witharia-current="true";selection="multiple"usesaria-pressed. A group with alabelis a namedgroup.- The leading icon and trailing cluster are decorative (
aria-hidden) — the row's text is its accessible name. Never put an interactive control in thetrailingof a button row. For a toggle, make the row the control (role="switch",aria-checked) and paint the state withSwitchVisual; for a realRadioorCheckbox, render the row as a plain element withrender={<div />}. Either way setcloseOnSelect={false}. - Rows meet the 44×44px touch floor on phones at every size (WCAG 2.5.5). The drag handle is decorative and not focusable — every gesture has a button equivalent.
- Every part exposes a stable
data-slot(action-sheet-content,action-sheet-header,action-sheet-group,action-sheet-item,action-sheet-footer,action-sheet-cancel, …) for testing and agent tooling. - All motion is disabled under
prefers-reduced-motion: reduce. The swipe is direct manipulation and stays available. - AT matrix: VoiceOver + Safari passed 2026-09-14 — the trigger reads "Post actions, dialog pop up button"; Space opens "Post actions, web dialog" and Tab walks "Like, button", "Comment, button"; Escape returns to the trigger. NVDA and JAWS untested.