Share Sheet
The system share surface: a subject header you can pull down to dismiss, a swipeable recents rail, a 4-up app grid and an activity list, on one scrollable sheet with safe-area padding.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/share-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ShareSheet" component — the OS share
surface: what is being shared on top, then the people you last sent to, the apps
you can send to, and the things you can do with it. React + lucide-react only:
no portal library, no gesture library, no animation library.
It is a SURFACE, not an overlay. Mounting, backdrop, modality and scroll locking
belong to whatever container it is dropped into (a drawer, a dialog, a fixed
layer). It never portals and never touches document.body. What it owns is
everything that makes it a phone component: the pull-down on its own header, the
horizontal rails, the safe-area inset at the bottom edge, and a keyboard path
for both.
Contract
- "use client". forwardRef<HTMLDivElement, ShareSheetProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "title">; the rest props spread onto
the root before className, so consumers can override the ARIA defaults.
- ShareSheetItem { key; label; hint?; icon?; onSelect: () => void |
Promise<unknown>; disabled?; disabledReason?; confirmLabel?; tone?: "default" |
"destructive" }. onSelect is REQUIRED: anything that renders as tappable has
real behaviour, never a decorative row. icon falls back to a monogram of label
(first + last initial, two letters for a single word).
- Props: title (required — the subject, and the group's accessible name),
description?, thumbnail?, recents = [], apps = [], activities = [],
variant = "grid" | "rail" | "list", visibleApps = 8, expanded /
defaultExpanded / onExpandedChange (controlled + uncontrolled), onDismiss?,
dismissOnSelect = true, rise = true, labels?: Partial<ShareSheetLabels> (the
eleven strings, for i18n).
- Variants are three presentations, not three colour schemes:
- "grid": recents as a swipeable avatar rail + apps as a 4-up grid capped at
visibleApps with an expand toggle + activities as rows.
- "rail": recents and apps MERGED into one swipeable row — nothing is dropped,
and visibleApps is ignored because a rail scrolls instead of truncating.
- "list": every band as full-width rows; the layout that survives the largest
text sizes and the shortest thumb reach.
Document per-variant props in their JSDoc (visibleApps and the expand toggle
do nothing in "rail"; tone only reads in the activity list).
- No clock anywhere: recency ordering and any "2h ago" wording are the
consumer's, passed in as hint strings. Nothing in the component reads
Date.now() during render, so the same props always paint the same sheet.
Behavior
- Selecting a target:
- disabled: the handler refuses and announces `${label}: ${disabledReason}`.
Never the native disabled attribute — the browser blurs a node the instant it
becomes disabled, and the reason has to stay readable and focusable.
- The one-shot guard is a ref read AND written synchronously in the handler:
one share at a time, so a double tap or a second tile during the round trip
is ignored rather than queued.
- onSelect is called inside try/catch. A synchronous handler settles
immediately and never flashes a spinner (no pending state is entered at all).
A thenable enters pending: a spinner badge on THAT cell, aria-busy on the
cell and on the root, and a polite announcement.
- Resolved: an item with a confirmLabel flashes a tick for 1.6s and holds the
sheet open to show it; everything else follows dismissOnSelect.
- Rejected: a failure badge and status text on that cell for 2.6s, announced,
and THE SHEET STAYS OPEN — a share that failed is exactly when the user needs
the targets again. The rejection is swallowed on purpose (an unhandled
rejection is worse); the documented recipe is to catch, report and rethrow
inside your own onSelect.
- Async completion is guarded by a mounted ref, and the ref is re-armed on
every mount so StrictMode's mount-unmount-remount does not kill the sheet.
- Pull-to-dismiss, on the grabber and subject header only (the body scrolls; the
header is always pinned, so there is always something to grab):
- Pointer Events only, never separate mouse/touch handlers. A press that lands
on a button, a link or an input is that element's click and starts no drag.
- 4px of movement turns a press into a drag; setPointerCapture goes on the node
the gesture started on, so a finger that slides off the header keeps driving
it and the release is never lost.
- The header carries touch-action: none, the scroll band touch-pan-y and the
rails touch-pan-x, so the browser never fights a gesture the sheet owns —
and preventDefault is therefore never needed on a passive listener.
- Downward tracks the finger 1:1. Upward is damped (0.3) and hard-capped at
24px: the sheet moves under the thumb, it never tears off the edge.
- Release: past 30% of the sheet's own height, or a downward fling above
0.5px/ms, counts as a dismissal. Velocity is smoothed (0.3 old / 0.7 new) so
one jittery frame is not a fling.
- ALWAYS spring home (260ms) after the release, even when it dismissed: a
consumer that ignores onDismiss must not be left with a sheet parked halfway
off the edge.
- The distance is written straight to the node's `translate` property, not
through state — a 60fps drag must not re-render the list. The entrance
animates `transform`; they are different properties, so they compose and a
drag started mid-entrance never wipes it.
- Expansion: apps beyond visibleApps hide behind one toggle with aria-expanded +
aria-controls. Collapsing unmounts tiles, so if focus was inside them (the
browser has already dropped it on <body>) hand it to the toggle — guarded by
the previous value, so a sheet that merely mounts collapsed never steals focus.
- Cleanup: the confirm/failure timer, the live-region timer and any in-flight
drag are all cancelled on unmount; the matchMedia subscription unsubscribes.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card / text-card-foreground on the
sheet, bg-muted + border for icon wells, text-muted-foreground for hints and
section headings, ring-ring for every focus ring. The subject thumbnail is the
highest-priority thing on the sheet, so it INVERTS (bg-foreground
text-background) instead of taking a colour; the confirmation badge does the
same. Colour is spent only where it means something: text-destructive for a
destructive activity, bg-destructive for the failure badge.
- Type ladder: title 14px/600, row label 14px/500, hint and tile label 11px,
section headings 11px uppercase with tracking. Surfaces: sheet rounded-t-2xl,
inner blocks rounded-lg, avatars rounded-full.
- Touch: tiles are 72px wide and ~90px tall, rows are min-h-14, the close button
is size-11 (44px). Nothing is hover-only — hover just adds bg-muted, and every
state that matters is in the markup.
- Safe area: the scroll band pads its bottom with
max(env(safe-area-inset-bottom), 1rem) so the last row clears the home
indicator, and the sheet pays env(safe-area-inset-left/right) because a
landscape notch eats one of them.
- The rails bleed into the sheet's own padding (-mx-4 px-4) so cards scroll under
the edge the way a system sheet does, and hide their scrollbar
([scrollbar-width:none] plus the ::-webkit-scrollbar variant).
- Accessibility:
- Root: role="group" + aria-labelledby pointing at the subject title, aria-busy
while a share is in flight.
- Each band is a <ul aria-labelledby> naming its own heading span — a named
list, not a landmark and not a heading level guessed on the consumer's
behalf.
- Every cell is a real <button type="button"> whose aria-label composes label +
hint + status (or the disabled reason), because the visible tile label is
clamped to two lines and the row label truncates.
- Keyboard map: Tab reaches every cell, the expand toggle and the close button;
Enter / Space activate (they are real buttons); ArrowLeft / ArrowRight / Home
/ End walk a rail, and focus() scrolls it, so the swipe is never the only way
to reach what is off the edge; Escape dismisses. Escape is claimed ONLY when
onDismiss exists — with no handler it bubbles to whatever container owns the
layer instead of being silently eaten.
- A polite sr-only role="status" announces starts, outcomes and refusals only
— never the drag. Every message is cleared after ~2.6s so the next identical
one is announced at all.
- prefers-reduced-motion (subscribed with useSyncExternalStore over matchMedia,
never read during render): the rise and the spring-back are dropped in the
stylesheet, the spinner stops spinning. The pull still follows the finger —
that is direct manipulation, not decoration — and every threshold, label and
announcement is unchanged.
- With no onDismiss the sheet renders no grabber and no close button, and starts
no drag: an affordance that cannot work is not drawn.
Customization levers
- variant is the layout switch: "grid" for the full system sheet, "rail" for a
quick-share bar above a composer, "list" for accessibility-first or very narrow
screens. Nothing is dropped between them, only rearranged.
- visibleApps sets how much grid is shown before the fold — 4 for one row, 8 for
two, apps.length to remove the toggle entirely.
- Bands are optional: pass only activities for an "actions" sheet, only recents +
apps for a pure "send to" sheet. An empty sheet says so in one line instead of
rendering three empty headings.
- dismissOnSelect false keeps the sheet up (a multi-send flow); confirmLabel is
the per-item exception that always keeps it up long enough to be read.
- labels is the i18n seam; nothing else in the component contains prose.
- Height belongs to the consumer: the sheet is a shrinkable flex column
(max-h-full min-h-0) whose body scrolls, so drop it into a fixed-height
container or a flex parent and it fits itself.
- rise={false} when the container already animates the entrance, or you get two.
- Skin it through data-slot="share-sheet" and data-variant on the root — a wider
grid (grid-cols-5), a taller tile, or a tinted band per variant, without
touching the state machine.
- Feel lives in four numbers: the 4px drag start, the 30% close ratio, the
0.5px/ms fling threshold and the 260ms spring. Raise the ratio for a sheet that
should be deliberate to dismiss; lower it for a peek.Concepts
- Surface, not overlay — the sheet owns its content and its own gesture; mounting, backdrop, modality and scroll locking stay with the container you drop it into. That is what keeps it composable with a drawer, a dialog or a plain fixed layer instead of being a fourth thing that portals to
document.bodyand fights the other three over who locks the page. - Three bands, one thumb order — people you already talk to, then apps, then things to do with the subject. The order is the reach order: what you tap most often sits nearest the bottom edge, and the pinned header at the top is the part you are least likely to hit by accident with a thumb — which is exactly why the pull-to-dismiss lives there and not on the list.
- Pull down on the header, scroll in the body — the split is deliberate and needs no scroll-position arithmetic: the header carries
touch-action: noneand the drag, the body carriestouch-pan-yand the scroll. Downward tracks the finger; upward is damped and capped at 24px so the sheet moves under the thumb but never tears off the edge. Every release springs home even when it dismissed, so a consumer that ignoresonDismissnever ends up with a sheet parked halfway off screen. - Every gesture has a twin — the pull is matched by a 44px close button and by Escape; the rail swipe is matched by
ArrowLeft/ArrowRight/Home/End, wherefocus()pans the rail exactly as a finger would. Escape is claimed only whenonDismissexists, so a sheet without one lets the key reach the container that actually manages the layer. - The share is a promise, and failure is a first-class state —
onSelectmay return a thenable; while it is pending the tile it was tapped on carries the spinner, and a rejection paints that cell and keeps the sheet open, because a failed send is precisely when the user still needs the targets. A one-shot ref, read and written synchronously in the handler, is what stops a double tap from sending twice; a state flag would let the second tap through. - Refusals stay reachable — an unavailable target is
aria-disabledwith its reason on the second line, never nativelydisabled: the browser blurs a disabled node instantly, so the user standing on it would be thrown to<body>and never hear why. The same rule shapes the collapse of the app grid, which hands focus to its own toggle rather than letting it fall to the page.
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.
Permission Primer
A pre-permission sheet that spends the phone's one-shot system dialog on purpose — reasons first, a swipe or a button to refuse, and a Settings round trip that re-checks itself when the app comes back.