Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, Loader2, TriangleAlert, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The rise ships with the component: React 19 hoists <style href> into the head
 * and de-dupes by href, so several sheets on one page still share one keyframes
 * block. The entrance is CSS-only on purpose — no rAF, no mount state, nothing
 * to desynchronise during hydration — and `prefers-reduced-motion` switches it
 * off in the stylesheet rather than in JavaScript.
 *
 * It animates `transform`, while the pull-to-dismiss drag writes the `translate`

Installation

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

Prompt

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.body and 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: none and the drag, the body carries touch-pan-y and 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 ignores onDismiss never 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, where focus() pans the rail exactly as a finger would. Escape is claimed only when onDismiss exists, 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 stateonSelect may 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-disabled with its reason on the second line, never natively disabled: 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.

On This Page