Action Sheet
An OS-style sheet of grouped choices raised from the bottom edge, with a destructive tone, a detached Cancel, drag-to-dismiss and safe-area padding.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/action-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ActionSheet" component (react-dom's
createPortal, no Radix, no vaul — the edge anchoring, the safe area and the
gesture are the whole point and must be self-contained). It is the mobile
action sheet: a list of discrete choices raised from the bottom edge into the
thumb arc, NOT a dropdown positioned against its trigger.
Contract
- Data: ActionSheetAction = { id: string; label: string; description?: string;
icon?: ReactNode; tone?: "default" | "destructive" | "primary";
disabled?: boolean; disabledReason?: string; keepOpen?: boolean;
onSelect?: () => void }. ActionSheetGroup = { id, label?, actions[] }.
- Props: groups?: ActionSheetGroup[]; actions?: ActionSheetAction[] (sugar for
one unlabelled group, ignored when groups has entries); open?, defaultOpen?,
onOpenChange? (controlled and uncontrolled both supported — controlled when
`open` is passed, otherwise the component owns it);
onSelect?: (id: string) => void; trigger?: ReactNode; variant?: "grouped" |
"edge" | "compact" (default "grouped"); title?; message?; label? (accessible
name when there is no title, default "Actions"); cancelLabel? (default
"Cancel"); showCancel? (default true); showHandle? (default true);
emptyLabel?; autoFocus? (default true); container?: HTMLElement | null.
className merges through cn() onto the sheet, the rest of the div props are
spread onto it, and the forwarded ref points at the sheet panel.
- trigger uses asChild semantics: a valid element is cloned so it keeps its own
type, styling and handlers while receiving aria-haspopup="menu",
aria-expanded, data-state and a merged onClick that bails when the consumer
already called preventDefault; anything else is wrapped in a default button.
Omit trigger entirely and drive `open` from wherever the gesture lives (a long
press, a row menu button).
- container is the portal target. Default document.body with a fixed layer — a
real full-screen sheet. Pass a relative, overflow-hidden element and the layer
becomes absolute inside it, which is what makes several sheets previewable
side by side in phone frames.
Behavior
- Position is one number: `offset`, 0 = fully raised, 1 = fully below the edge,
expressed as a fraction of the sheet's own height so it maps straight to a
translate3d percentage and needs no measurement to render.
- Enter, settle and exit all run through one effect that pushes offset to its
target inside a double requestAnimationFrame, so the parked frame is really
painted before the transition starts (otherwise the browser coalesces both
style changes and the sheet teleports). A settle counter sits in that effect's
deps so every gesture end re-runs it even when the target did not change. The
portal stays mounted for the 280ms exit, then unmounts.
- Gesture: Pointer Events only, never separate mouse/touch handlers. A press is
not a drag — it becomes one after 4px of movement, and only if vertical
movement exceeds horizontal, otherwise the gesture is abandoned for good so a
diagonal swipe does not stutter between two interpretations. On arming, call
setPointerCapture on the element that started the gesture and release it on
that same node; ignore any pointerId other than the one that owns the drag.
- Only the handle and the header start a drag (they carry a data attribute and
touch-action: none). A press that lands on a row belongs to that row, and a
press inside the list belongs to the list's own scrolling (touch-action:
pan-y + overscroll-contain). Nothing calls preventDefault, so nothing needs a
non-passive listener.
- Downward travel is 1:1 with the thumb; upward is rubber-banded (30% of the
excess, capped at 4% of the height) so the sheet moves but cannot be torn off
the edge. Release dismisses when the smoothed velocity exceeds 0.5 px/ms OR
the sheet is past 35% of its own height; otherwise it springs back. Velocity
is smoothed 30/70 so one jittery frame is not a fling.
- A dismissal only calls onOpenChange(false). If a controlled consumer refuses,
the sheet springs back to its stop instead of being stranded halfway down —
which is why the settle counter is bumped on every gesture end, dismiss or not.
- Selecting a row runs action.onSelect, then onSelect(id), then closes unless
keepOpen is set (a toggle answers in place). A ref that is read AND written
synchronously in the same handler guarantees one selection per opening: a
double tap, or a second finger landing on another row during the exit
animation, cannot fire twice. The guard is re-armed when the sheet opens.
- Disabled rows are aria-disabled with a handler guard, never the native
disabled attribute — the browser blurs a node the instant it becomes disabled,
which would drop focus onto <body> mid-sheet. Pressing one keeps the sheet up,
shows disabledReason under the label and announces it in a polite live region
that self-clears after 2.4s so an identical refusal can be announced again.
- Keyboard: ArrowDown / ArrowUp walk a ring of every row plus Cancel and wrap,
Home / End jump to the ends, Enter / Space activate (they are real buttons),
Esc dismisses, Tab is trapped inside the sheet. Rows use a roving tabindex
(one row tabbable at a time, updated on focus) as menu semantics require;
Cancel deliberately stays permanently tabbable, because the escape hatch must
never be more than one Tab away.
- Escape is handled on the sheet's own onKeyDown with stopPropagation, never a
window listener: a window listener cannot tell which layer is on top, so one
Esc with anything open behind the sheet closes both. The consumer's onKeyDown
is called first and defaultPrevented is respected.
- Focus: on open, remember document.activeElement and move focus to the first
enabled row (or Cancel, or the panel). On close, restore it only if that
element is still isConnected, otherwise fall back to whatever the trigger is
now — the row that opened the sheet is frequently gone by then, and restoring
blindly drops focus to <body>. autoFocus={false} skips the whole entrance
focus move, for static previews only.
- Body scroll lock, and only when the sheet owns the whole screen (no
container): the reentrancy count and the pre-lock snapshot live in
document.body data attributes, never module-level variables, because every
installed copy of this pattern has its own module scope and two private
counters cannot cooperate — nest two overlays and the page stays frozen with
nothing on screen to explain it. Scrollbar compensation is MEASURED (read
clientWidth, set overflow hidden, read it again, add the difference), not
predicted from innerWidth - clientWidth, which is wrong under
scrollbar-gutter: stable.
- prefers-reduced-motion is subscribed via matchMedia (not read once): all
transitions become none and the exit unmounts immediately. Dragging,
dismissing and selecting all still work — the decoration goes, the feature
stays.
- Cleanup: pointer capture released, drag rAF cancelled, exit timer cleared,
refusal timer cleared, scroll lock decremented, matchMedia unsubscribed — on
unmount and on every dependency change.
- Degenerate cases are first-class: no groups at all renders emptyLabel and
still offers Cancel; a single action is a single card; a long label wraps and
grows its row instead of truncating; a list taller than the screen scrolls
while the header and Cancel stay pinned.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card / text-card-foreground cards,
border hairlines and divide-y between rows, text-muted-foreground for the
message and group captions, bg-background/80 + backdrop-blur-sm scrim, focus
rings focus-visible:ring-2 ring-ring ring-inset. No hex, no rgb(), no oklch().
- Colour is spent only on real semantics: tone="destructive" is text-destructive
with a destructive/10 press state. The highest-priority row INVERTS
(bg-foreground / text-background) rather than taking a colour, at most one per
sheet.
- Three presentations, structurally different rather than recoloured:
"grouped" = inset gutter, one rounded-2xl card per group, header inside the
first card, centred labels, Cancel in its own detached card below;
"edge" = one full-bleed card flush with the bottom edge, rounded on top only,
left-aligned rows with leading icons, header and Cancel pinned outside the
scrolling list; "compact" = a single dense card, 44px rows, trailing icons,
Cancel folded in as the last row.
- Touch: rows are min 56px (44px in compact) and the grab handle sits in a 44px
band; padding does the work, so a two-line label grows the row. Nothing
depends on hover — every hover state has an :active twin.
- Safe area: the sheet pads itself with
max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), floor)
on bottom, left and right, so Cancel clears the home indicator and the rows
clear the landscape rails. Reading the custom property first is what lets a
device frame or a test simulate insets on hardware that reports 0.
- Accessibility: the list is role="menu" named by the title (or `label`) and
described by the message; each group is role="group" labelled by its caption;
rows are role="menuitem" buttons. The visible header is aria-hidden yet still
the source of those names — aria-labelledby resolves through hidden nodes, so
the heading is announced once as part of the menu instead of twice. The scrim
is aria-hidden and the handle is decorative, because Cancel and Esc are the
announced ways out.
Customization levers
- Presentation: `variant` is the main dial — grouped for a decision, edge for a
long file/list menu, compact for a row-level menu. Chrome comes off
independently: showHandle removes the grab handle (and with it the drag zone
unless a header is present), showCancel removes the Cancel card, omitting
title and message removes the header block.
- Gesture feel: the 4px arm threshold, the 0.5 px/ms fling threshold, the 35%
dismiss ratio, the 280ms settle and the cubic-bezier(0.32, 0.72, 0, 1) easing
are five constants at the top of the file. Raise the ratio for a stickier
sheet, lower the duration for a snappier one.
- Density: the row height and type scale live in one map keyed by variant
(min-h-14 / text-[15px], min-h-11 / text-[13px]); change them there rather
than per row. Icon side is per variant too (leading, trailing in compact).
- Safe area floors: the second argument of the inset helper is the minimum
gutter on hardware that reports 0 — raise it for a roomier sheet, drop it to
0px for a flush one.
- Semantics: tone drives colour, not the other way round. Keep destructive for
the one irreversible row and primary for the one recommended row; anything
else stays default so the two that matter still read as exceptions.
- Embedding: pass `container` to keep the sheet inside a device frame or a
preview pane; leave it off in the app. Pair the component with your own long
press or row button by omitting `trigger` and driving `open` yourself.Concepts
- Bottom-edge origin — the sheet is anchored to the edge and ignores where it was triggered from. That is the whole difference from a dropdown: on a phone the trigger is often at the top of the screen and the thumb is at the bottom, so the choices are put where the thumb is, not where the finger last was.
- Detached Cancel — Cancel is its own card with a gap above it, sitting under everything else and inside the safe area. The gap is functional, not decorative: it is the one target a panicking thumb can hit without landing on the destructive row, and it is why Cancel stays permanently tabbable while the rows share a roving tabindex.
- Safe-area floor — every edge inset reads
--safe-area-inset-*first,env()second, and a floor third. The custom property is what lets a device frame simulate a home indicator on a desktop browser; the floor is what keeps a gutter on hardware that honestly reports 0. Without both, the sheet either hugs the indicator on a phone or has no margin anywhere else. - Press-versus-drag arbitration — only the handle and header can begin a gesture, so a press on a row is unambiguously that row and a long list still scrolls natively. The alternative (making the whole sheet draggable) forces every row press to wait out a threshold before it can commit, which reads as lag.
- One-shot selection guard — a ref written and read inside the same handler, not state. State updates are asynchronous, so a double tap or a second finger during the 280ms exit would run two actions from one opening; the ref closes that window synchronously and is re-armed when the sheet next opens.
- Refusal instead of deadness — an unavailable row keeps its name, its focus and its place in the arrow ring, states why under its label, and announces that reason politely when pressed. A natively disabled row would blur itself out from under the user and answer nothing.
List Index Bar
An edge-hugging A–Z rail that jumps a sectioned list from one continuous thumb drag, with a magnified preview bubble, dot compression and a full keyboard path.
Context Peek
The long-press peek: the held target lifts out of a blurred page and its actions resolve under the finger, as a menu, an icon toolbar or an expanded preview card.