Share Button
A share button on the Web Share API: opens the native share sheet where supported, and falls back to a copy-link + social-intents menu everywhere else.
on a phone this opens the OS share sheet · on desktop, the fallback menu
Usage
import { ShareButton } from "~/components/share/share-button";<ShareButtontitle="A great article"text="You should read this"url="https://seangeng.com/writing/…"/>
Or build your own UI on the useWebShare hook:
import { useWebShare } from "~/hooks/use-web-share";function Share() {const { isSupported, share } = useWebShare();return (<button onClick={async () => {const r = await share({ title: "Hi", url: location.href });if (r === "unsupported") openFallbackMenu();}}>{isSupported ? "Share" : "Copy link"}</button>);}
Native first, fallback always
The Web Share API is one of those platform features that feels like cheating.navigator.share({ title, text, url }) hands the data to the operating system, which opens the real share sheet, the same one every native app uses. The user picks Messages, AirDrop, WhatsApp, whatever, and your job is done in one line.
The catch is coverage. It's everywhere on mobile and in Safari, and in Chrome, but not in every desktop browser. So the button checks for navigator.share and only uses it when it's there. When it isn't, it opens a small menu instead: copy link, plus the standard social intent URLs (X, LinkedIn, Facebook, WhatsApp, email). Same button, graceful everywhere.
async function onClick() {// Native share sheet on phones, Safari, Chrome…if (navigator.share) {try {await navigator.share({ title, text, url });return;} catch {// user cancelled — fall through}}// …otherwise open a copy-link + social menu.setOpen((o) => !o);}
Props
| prop | type | default | description |
|---|---|---|---|
| url | string | current page | URL to share. |
| title | string | "Check this out" | Share title. |
| text | string | "" | Share text / message. |
| label | string | "Share" | Button label. |
Inspired by Bilal Hussain.
Source
download .zipThe full implementation, across 3 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useEffect, useRef, useState } from "react";import { Share2, Link2, Check, Mail } from "lucide-react";import { SiX, SiWhatsapp, SiFacebook } from "react-icons/si";import { FaLinkedinIn } from "react-icons/fa6";import { cn } from "~/lib/utils";import { useWebShare } from "~/hooks/use-web-share";import { useClipboard } from "~/hooks/use-clipboard";export interface ShareButtonProps {/** URL to share. Defaults to the current page. */url?: string;title?: string;text?: string;label?: string;className?: string;}/*** Share button built on the Web Share API. On supported devices (most phones,* Safari, Chrome) it opens the native share sheet via `navigator.share`. Where* that isn't available it falls back to a small menu: copy link plus the usual* social share intents. Inspired by Bilal Hussain (@BilliCodes).*/export function ShareButton({url,title = "Check this out",text = "",label = "Share",className,}: ShareButtonProps) {const [open, setOpen] = useState(false);const ref = useRef<HTMLDivElement>(null);const { share } = useWebShare();const { copied, copy } = useClipboard();const shareUrl =url ?? (typeof window !== "undefined" ? window.location.href : "");useEffect(() => {if (!open) return;const onDoc = (e: MouseEvent) => {if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);};document.addEventListener("mousedown", onDoc);return () => document.removeEventListener("mousedown", onDoc);}, [open]);async function onClick() {// Native sheet first; open the fallback menu only when it isn't available.const result = await share({ title, text, url: shareUrl });if (result === "unsupported") setOpen((o) => !o);}const enc = encodeURIComponent;const targets = [{ label: "X", Icon: SiX, href: `https://twitter.com/intent/tweet?text=${enc(text || title)}&url=${enc(shareUrl)}` },{ label: "LinkedIn", Icon: FaLinkedinIn, href: `https://www.linkedin.com/sharing/share-offsite/?url=${enc(shareUrl)}` },{ label: "Facebook", Icon: SiFacebook, href: `https://www.facebook.com/sharer/sharer.php?u=${enc(shareUrl)}` },{ label: "WhatsApp", Icon: SiWhatsapp, href: `https://wa.me/?text=${enc(`${text || title} ${shareUrl}`)}` },{ label: "Email", Icon: Mail, href: `mailto:?subject=${enc(title)}&body=${enc(`${text}\n\n${shareUrl}`)}` },];return (<div ref={ref} className={cn("relative inline-block", className)}><buttontype="button"onClick={onClick}aria-haspopup="menu"aria-expanded={open}className="inline-flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-opacity hover:opacity-90 active:translate-y-px"><Share2 className="size-4" />{label}</button>{open && (<divrole="menu"className="absolute left-0 top-[calc(100%+0.5rem)] z-20 w-52 overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-1 shadow-xl"><buttontype="button"onClick={() => copy(shareUrl)}className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-[hsl(var(--muted))]">{copied ? (<Check className="size-4 text-[hsl(var(--primary))]" />) : (<Link2 className="size-4 text-[hsl(var(--muted-foreground))]" />)}{copied ? "Copied link" : "Copy link"}</button><div className="my-1 h-px bg-[hsl(var(--border))]" />{targets.map(({ label, Icon, href }) => (<akey={label}href={href}target="_blank"rel="noreferrer"onClick={() => setOpen(false)}className="flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors hover:bg-[hsl(var(--muted))]"><Icon className="size-4 text-[hsl(var(--muted-foreground))]" />{label}</a>))}</div>)}</div>);}
import { useEffect, useState } from "react";export interface ShareData {title?: string;text?: string;url?: string;}export type ShareResult = "shared" | "dismissed" | "unsupported";/*** Wrap the Web Share API (navigator.share). `isSupported` is detected on the* client (false during SSR and on browsers without the API). `share()` opens* the native sheet and resolves to a tagged result so the caller can decide* what to do — e.g. open a fallback menu when it's "unsupported".** const { isSupported, share } = useWebShare();* const r = await share({ title, url });* if (r === "unsupported") openFallbackMenu();*/export function useWebShare() {const [isSupported, setIsSupported] = useState(false);useEffect(() => {setIsSupported(typeof navigator !== "undefined" && typeof navigator.share === "function",);}, []);async function share(data: ShareData): Promise<ShareResult> {if (typeof navigator === "undefined" || typeof navigator.share !== "function")return "unsupported";try {await navigator.share(data);return "shared";} catch {// AbortError (user cancelled) or any failurereturn "dismissed";}}return { isSupported, share };}
import { useCallback, useState } from "react";/*** Copy text to the clipboard and flash a `copied` flag that resets itself.** const { copied, copy } = useClipboard();* <button onClick={() => copy(url)}>{copied ? "Copied" : "Copy"}</button>*/export function useClipboard(resetMs = 1500) {const [copied, setCopied] = useState(false);const copy = useCallback(async (text: string) => {try {await navigator.clipboard.writeText(text);setCopied(true);setTimeout(() => setCopied(false), resetMs);return true;} catch {return false;}},[resetMs],);return { copied, copy };}