useWebShare
Opens the native share sheet and degrades honestly — a cancelled sheet is reported as dismissed rather than an error, files are checked with canShare first, and the fallback's own success or failure is reported as such.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-web-share.jsonPrompt
Build a React + TypeScript "useWebShare" hook (React only — no npm
dependencies; browser Web Share API: navigator.share and navigator.canShare).
Contract
- `useWebShare({ onSuccess, onError, fallback } = {})`.
- Returns `{ isSupported, canShare, share, status, error }`.
- `status` is `"idle" | "sharing" | "shared" | "dismissed" | "error"`.
`dismissed` is its own state: the user closed the sheet. It is NOT an error.
- `error: WebShareError | null` — non-null only while `status === "error"`.
`WebShareError` is `{ kind, message, cause: WebShareError | null }` with
`kind` in `"unsupported" | "insecure-context" | "invalid-data" |
"not-allowed" | "already-sharing" | "share-failed" | "fallback-failed"`.
`cause` is filled only for `fallback-failed`, and holds the failure that
forced the degradation. UI copy branches on `kind`; `message` is the
browser's own, unlocalised text.
- `share(data: ShareData): Promise<WebShareResult>` where `WebShareResult` is
`{ outcome: "shared", via: "web-share" | "fallback" }` | `{ outcome:
"dismissed" }` | `{ outcome: "error", error }`. It never throws — every
ending is in the return value. Referentially stable.
- `canShare(data: ShareData): boolean` — a runtime probe, referentially
stable. It reads `navigator` when called, so it must not be called during
render (see the capability-detection rule below).
- `isSupported` — `navigator.share` exists. `false` during SSR and on the
hydrating first paint.
- `onSuccess(via)` fires for both routes, so consumers can say "Copied link"
instead of "Shared". `onError(error)` fires only for real failures.
`fallback(data, reason)` runs when the share is impossible or failed.
All three are held in latest-refs and never enter a dependency array.
Behavior
- A cancelled share sheet rejects with `AbortError`. Map it to
`outcome: "dismissed"`: leave `error` at null, do NOT call `onError`, and do
NOT run the fallback — the user just said "no", so copying a link behind
their back is worse than doing nothing. Treating AbortError as a failure is
the single most common bug with this API: every "Cancel" press would raise a
"Share failed" toast. Honest caveat to document: Chromium also reports some
internal share-service faults as `AbortError`, and a page cannot tell them
apart, so they land in `dismissed` too.
- Web Share requires transient user activation, otherwise `NotAllowedError`.
So every pre-check is synchronous and `navigator.share(data)` is called
BEFORE the function's first `await`, which keeps `onClick={() =>
share(data)}` valid. (Verified in a harness: a counter incremented by a
stubbed `navigator.share` already reads 1 on the statement right after
`share()` returns its promise.) Document that the consumer must not `await`
anything else before calling it.
- Pre-checks, in order, all synchronous:
1. no `navigator.share` → `unsupported`, unless `window.isSecureContext ===
false`, which yields `insecure-context` instead. Web Share is a
secure-context API, so on http:// the whole API is simply missing; saying
"unsupported browser" there sends the user off to install another browser
when the real fix is the URL.
2. `canShare(data)` false → `invalid-data`, with a different message when the
payload carries files. Never call `share({ files })` without asking
`canShare` first: file sharing is gated behind a MIME allowlist and is far
narrower than text/URL sharing, and an unsupported file throws a
`TypeError` instead of rejecting.
- `canShare` implementation: `navigator.canShare` and `navigator.share` shipped
separately, so when only `share` exists, allow text payloads and refuse
anything carrying files — an unverifiable file share is not worth the thrown
TypeError. Wrap the call in try/catch (the spec says it returns false, but
implementations have thrown on malformed payloads).
- Failure classification from the rejection's `name`: `NotAllowedError` →
`not-allowed` (no user gesture, or a `web-share` permissions policy),
`TypeError` → `invalid-data`, `InvalidStateError` → `already-sharing`,
anything else → `share-failed`.
- The fallback is a MAIN ROUTE, not decoration: desktop Firefox has no
`navigator.share` at all. It runs for every pre-check failure and every real
error, never for a dismissal. Its own outcome is reported honestly:
returning `false`, or throwing synchronously, or rejecting, produces
`fallback-failed` with `cause` set to the original reason; anything else
produces `outcome: "shared", via: "fallback"`. Await the fallback inside a
try/catch in an async function so a synchronous throw is caught too —
`Promise.resolve(fn())` would let a synchronous throw escape before the
promise ever sees it. Document the footgun that a fallback returning
`undefined` counts as success, so consumers should return the clipboard
helper's boolean instead of discarding it.
- With no fallback, the original error is returned as-is.
- Concurrency: keep a `pendingRef`. A second `share()` while one is in flight
returns an `already-sharing` error WITHOUT touching `status`, without calling
`onError` and without running the fallback — the open sheet still owns the
status, and flashing an error (or quietly copying a link) underneath it would
be worse. A browser-reported `InvalidStateError` maps to the same `kind`.
- Capability detection never happens during render: `isSupported` goes through
`useSyncExternalStore(noopSubscribe, detect, () => false)` so SSR and
hydration agree. `canShare` stays a call-time probe; when a render-time
decision is needed (disabling a "Share file" button), the CONSUMER caches one
probe result and feeds it through `useSyncExternalStore` as well — a snapshot
must be stable, so it has to be cached.
- `status` and `error` live in one state object so they can never disagree.
Entering `sharing` clears `error`.
- After the `await`, re-check a `mountedRef` (set to true INSIDE the mount
effect, not only cleared in cleanup — StrictMode's mount → cleanup → mount
would otherwise leave a live instance marked unmounted): skip the setState
and the consumer callbacks when the component is gone, but still return the
result to whoever is awaiting.
Rendering & styling
- The hook renders nothing. The rule for the consumer's UI is: never show one
outcome dressed as another. Three visually distinct endings — a success line
whose wording depends on `via`, a MUTED "Cancelled" line for `dismissed`
(`text-muted-foreground`, no destructive tone, no toast), and a destructive
block for `error` carrying the `kind` as a badge plus a per-kind recovery
hint ("this browser has no share sheet", "the page is on http://", "allow
clipboard access").
- Semantic tokens only: `bg-card`, `bg-muted/40`, `text-muted-foreground`,
`border`, and `border-destructive/40 bg-destructive/10 text-destructive`
(plus `dark:bg-destructive/20`) for the failure block.
- Put the result panel in an `aria-live="polite"` region: the outcome arrives
long after the click, and the share sheet steals focus while it is open.
- A "share a file" button that cannot work should be genuinely `disabled` with
the reason rendered next to it — an enabled button that throws a TypeError is
a lie. Keep that reason visible rather than hiding it in a `title`.
- Long share URLs and browser error messages need `break-words`. Watch flex
containers: every inline child of a `flex` row becomes a flex item, so a
paragraph mixing prose with code spans must wrap all of it in ONE
`min-w-0 break-words` span, otherwise the pieces line up in a single
un-wrapping row (measured: 122px of horizontal overflow at a 390px viewport).
Customization levers
- `fallback` — the whole degradation strategy. Copy the link (return the
clipboard helper's boolean), open a `mailto:`, or open your own share menu;
return `false` or throw when it did not work.
- `onSuccess` / `onError` — where a toast, an analytics event, or a "Copied!"
affordance hangs off. Read `via` to pick the wording.
- The payload — `{ title, text, url }` for a page, `{ files }` for an export or
a screenshot, or both; `canShare` is what tells you whether the richer
payload is safe here.
- Want an auto-reset ("Copied!" fading back to idle)? Wrap `status` in a timer
on the consumer side; the hook keeps the last outcome on purpose so a panel
can keep explaining it.
- Want the dismissal louder or quieter? It is a separate state precisely so you
can decide — most apps show nothing at all for `dismissed`.
- Multi-target UI (WhatsApp / X / email columns) is not this hook's job: use it
as the "native share" entry and let a menu component own the rest.Concepts
- Cancel is not a failure — closing the share sheet rejects with
AbortError, which becomes its owndismissedstate:errorstaysnull,onErrornever fires, and the fallback does not run. Acatchthat treats every rejection as a failure turns each "Cancel" press into a "Share failed" toast, and a fallback wired into that catch quietly copies a link the user just declined to share. Documented caveat: Chromium reports a few internal share-service faults asAbortErrortoo, and a page cannot tell them apart. - Gesture-preserving call — Web Share needs transient user activation, so every pre-check is synchronous and
navigator.share()is dispatched before the hook's firstawait. Measured in a harness with a stubbednavigator.share: the call counter already reads1on the statement right aftershare()returns its promise. Anything the consumer awaits before callingshare()still loses the activation and earns aNotAllowedError. - Ask before you share files — file sharing rides a MIME allowlist and is much narrower than text or URL sharing, and handing
filesstraight toshare()throws aTypeErrorrather than rejecting. SocanShare(data)is checked first, and when a browser exposessharebut notcanShare, file payloads are refused rather than gambled on. - Honest degradation — the fallback is the main route on desktop Firefox, not an edge case, and it reports its own outcome:
falseor a throw becomesfallback-failed(withcausenaming what forced the degradation), anything else becomessharedwithvia: "fallback", so the UI can say "Copied link" instead of "Shared". A fallback returningundefinedreads as success — return the clipboard helper's boolean. - Capability detection outside render —
isSupportedcomes fromuseSyncExternalStorewith afalseserver snapshot, so SSR and hydration agree;canSharestays a call-time probe, and a render-time decision such as disabling a "Share file" button is made by caching one probe result and feeding it throughuseSyncExternalStoretoo (a snapshot has to be stable). - One owner per sheet — a second
share()while a sheet is open returnsalready-sharingwithout touchingstatus, without callingonErrorand without running the fallback, because the in-flight share still owns the visible state; the samekindis used when the browser reports the collision itself asInvalidStateError.
usePermission
Subscribes to navigator.permissions for one name or a whole panel of them — query only, never request — folding Firefox's rejections, Safari's gaps, unknown names and insecure origins into one honest unsupported state with a reason.
useSpeechRecognition
Speech-to-text on the Web Speech API — interim vs settled transcripts, a continuous mode that survives the engine's silence timeout without becoming unstoppable, and errors split into benign / recoverable / blocked.