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.
Playground
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>When to use
- Use an action sheet for a short list of actions that follow from something the person just did — a "more" button, a long-press, a share affordance — on phones and narrow viewports.
- Use a Dropdown Menu where there is a pointer and room to anchor a panel to its trigger. Keyboard shortcuts, cascading submenus and hover-open belong there, not here. See Responsive for the swap.
- Use a Modal when the person must read something or fill something in before continuing, and a Drawer for a panel with its own workflow (filters, forms, inspectors).
- Keep it short. Carbon caps context menus at 12 items, Atlassian at 15, and Apple asks you to avoid scrolling an action sheet at all. If your list scrolls far, it is a Drawer or a page.
- Labels are verbs. "Delete photo", not "Photo deletion". Atlassian's 24-character ceiling is a good target; the row truncates rather than wrapping.
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 — the convention Carbon, Fluent and Primer share. (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 what Material 3 specifies for compact widths.
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.
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 switch in the trailing slot. A row whose
trailing control is the affordance sets closeOnSelect={false} so the sheet stays open.
Account switcher
selection="single" marks the current row with aria-current and a trailing check. 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 — the order Material 3, Apple and Astryx all
specify. 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.
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" | "ltr" | Text direction applied to the panel and all children |
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" | 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. - The panel is capped at 480px and centered on viewports wider than that.
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 intrailing; give the rowcloseOnSelect={false}and let the whole row be the target instead. - 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 verified locally. NVDA and JAWS untested — the
component stays
betauntil both are on record.