Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"

/** Open / close / settle duration (ms). Also how long the exit is held before the sheet unmounts. */
const SETTLE_MS = 280
/** Movement (px) on a drag zone before a press becomes a drag. Below it, a press is still a press. */
const DRAG_START_PX = 4
/** Released past this fraction of the sheet's own height, it dismisses instead of springing back. */
const CLOSE_RATIO = 0.35
/** Fling threshold (px/ms): a fast flick dismisses from anywhere, even four pixels in. */
const FLING_VELOCITY = 0.5

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/action-sheet.json

Prompt

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.

On This Page