// src/components/ShareButton/ShareButton.tsx
"use client";
/**
* ShareButton — a "Share" pill that morphs open into share targets
* Stack: React + TypeScript + Tailwind CSS v4 + Motion (motion/react)
* Deps: npm i motion react-icons
*
* - Click "Share": the pill stretches (layout animation) and the targets fan in
* - Each target lifts on hover and fills with its brand colour
* - Copy link swaps to a check mark and announces "Link copied" to screen readers
* - "More" opens the device's native share sheet where supported
* - Escape / click outside closes and returns focus to the trigger
*/
import { useEffect, useRef, useState, type CSSProperties } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react";
import type { IconType } from "react-icons";
import {
FaXTwitter,
FaLinkedinIn,
FaFacebookF,
FaWhatsapp,
FaTelegram,
FaReddit,
} from "react-icons/fa6";
import {
FiShare2,
FiLink,
FiCheck,
FiX,
FiMail,
FiMoreHorizontal,
} from "react-icons/fi";
export type ShareNetwork =
| "x"
| "linkedin"
| "facebook"
| "whatsapp"
| "telegram"
| "reddit"
| "email";
interface ShareInfo {
url: string;
title: string;
text: string;
}
interface NetworkConfig {
label: string;
icon: IconType;
color: string;
href: (info: ShareInfo) => string;
}
export interface ShareButtonProps {
/** Defaults to the current page URL */
url?: string;
/** Defaults to document.title */
title?: string;
/** Message sent with the link; defaults to the title */
text?: string;
/** Which targets to show, in order */
networks?: ShareNetwork[];
/** Trigger text */
label?: string;
className?: string;
}
const enc = encodeURIComponent;
const NETWORKS: Record<ShareNetwork, NetworkConfig> = {
x: {
label: "Share on X",
icon: FaXTwitter,
color: "#0f1419",
href: ({ url, text }) =>
`https://x.com/intent/post?url=${enc(url)}&text=${enc(text)}`,
},
linkedin: {
label: "Share on LinkedIn",
icon: FaLinkedinIn,
color: "#0a66c2",
href: ({ url }) =>
`https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`,
},
facebook: {
label: "Share on Facebook",
icon: FaFacebookF,
color: "#1877f2",
href: ({ url }) =>
`https://www.facebook.com/sharer/sharer.php?u=${enc(url)}`,
},
whatsapp: {
label: "Share on WhatsApp",
icon: FaWhatsapp,
color: "#25d366",
href: ({ url, text }) => `https://wa.me/?text=${enc(`${text} ${url}`)}`,
},
telegram: {
label: "Share on Telegram",
icon: FaTelegram,
color: "#26a5e4",
href: ({ url, text }) =>
`https://t.me/share/url?url=${enc(url)}&text=${enc(text)}`,
},
reddit: {
label: "Share on Reddit",
icon: FaReddit,
color: "#ff4500",
href: ({ url, title }) =>
`https://www.reddit.com/submit?url=${enc(url)}&title=${enc(title)}`,
},
email: {
label: "Share by email",
icon: FiMail,
color: "#525252",
href: ({ url, title, text }) =>
`mailto:?subject=${enc(title)}&body=${enc(`${text}\n\n${url}`)}`,
},
};
const DEFAULT_NETWORKS: ShareNetwork[] = [
"x",
"linkedin",
"whatsapp",
"telegram",
"reddit",
"email",
];
const morph: Transition = { type: "spring", stiffness: 380, damping: 32 };
const listVariants: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.06 } },
};
const itemVariants: Variants = {
hidden: { opacity: 0, scale: 0.4, y: 8 },
show: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: "spring", stiffness: 420, damping: 24 },
},
};
const iconBtn =
"grid size-10 place-items-center rounded-full text-neutral-700 outline-none transition-colors " +
"hover:bg-(--brand) hover:text-white focus-visible:bg-(--brand) focus-visible:text-white " +
"focus-visible:ring-2 focus-visible:ring-(--brand) focus-visible:ring-offset-2 " +
"dark:text-neutral-300 dark:focus-visible:ring-offset-neutral-900";
const brand = (color: string) => ({ "--brand": color }) as CSSProperties;
async function copyText(value: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// Fallback for non-secure contexts (http://)
const el = document.createElement("textarea");
el.value = value;
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.select();
const ok = document.execCommand("copy");
el.remove();
return ok;
}
}
export default function ShareButton({
url,
title,
text,
networks = DEFAULT_NETWORKS,
label = "Share",
className = "",
}: ShareButtonProps) {
const reduce = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [info, setInfo] = useState<ShareInfo>({ url: "", title: "", text: "" });
const [canNativeShare, setCanNativeShare] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstItemRef = useRef<HTMLAnchorElement>(null);
const restoreFocus = useRef(false);
const copyTimer = useRef<number | undefined>(undefined);
// Resolve page details at click time (safe for SSR)
const openMenu = () => {
const resolvedTitle = title ?? document.title;
setInfo({
url: url ?? window.location.href,
title: resolvedTitle,
text: text ?? resolvedTitle,
});
setCanNativeShare(typeof navigator.share === "function");
setOpen(true);
};
const closeMenu = (returnFocus = true) => {
restoreFocus.current = returnFocus;
setOpen(false);
};
// Focus handling
useEffect(() => {
if (open) {
firstItemRef.current?.focus();
} else if (restoreFocus.current) {
triggerRef.current?.focus();
restoreFocus.current = false;
}
}, [open]);
// Escape + click outside
useEffect(() => {
if (!open) return;
const onPointerDown = (e: globalThis.PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) closeMenu(false);
};
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") closeMenu(true);
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
useEffect(() => () => window.clearTimeout(copyTimer.current), []);
const handleCopy = async () => {
const ok = await copyText(info.url);
if (!ok) return;
setCopied(true);
window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
};
const handleNativeShare = async () => {
try {
await navigator.share({
title: info.title,
text: info.text,
url: info.url,
});
} catch {
/* user dismissed the sheet */
}
};
const lift = reduce ? undefined : { y: -3 };
return (
<motion.div
ref={rootRef}
layout
transition={reduce ? { duration: 0 } : morph}
style={{ borderRadius: 999 }}
className={`relative inline-flex items-center overflow-hidden border border-neutral-200 bg-white p-1 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 ${className}`}
>
<AnimatePresence mode="popLayout" initial={false}>
{!open ? (
<motion.button
key="trigger"
ref={triggerRef}
type="button"
onClick={openMenu}
aria-expanded={false}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}
whileTap={{ scale: 0.94 }}
className="flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium text-neutral-800 outline-none hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800 dark:focus-visible:ring-white"
>
<FiShare2 size={16} aria-hidden="true" />
{label}
</motion.button>
) : (
<motion.ul
key="menu"
aria-label="Share this page"
variants={listVariants}
initial="hidden"
animate="show"
exit={{ opacity: 0, transition: { duration: 0.1 } }}
className="flex items-center gap-1"
>
{networks.map((id, i) => {
const n = NETWORKS[id];
const Icon = n.icon;
return (
<motion.li key={id} variants={itemVariants}>
<motion.a
ref={i === 0 ? firstItemRef : undefined}
href={n.href(info)}
target="_blank"
rel="noopener noreferrer"
aria-label={n.label}
title={n.label}
whileHover={lift}
whileTap={{ scale: 0.9 }}
style={brand(n.color)}
className={iconBtn}
>
<Icon size={17} aria-hidden="true" />
</motion.a>
</motion.li>
);
})}
<motion.li
variants={itemVariants}
role="presentation"
aria-hidden="true"
>
<span className="mx-1 block h-6 w-px bg-neutral-200 dark:bg-neutral-700" />
</motion.li>
{/* Copy link */}
<motion.li variants={itemVariants}>
<motion.button
type="button"
onClick={handleCopy}
aria-label={copied ? "Link copied" : "Copy link"}
title="Copy link"
whileHover={lift}
whileTap={{ scale: 0.9 }}
style={brand(copied ? "#16a34a" : "#404040")}
className={`${iconBtn} ${copied ? "bg-(--brand) text-white dark:text-white" : ""}`}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={copied ? "check" : "link"}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, rotate: -90, scale: 0.5 }
}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, rotate: 90, scale: 0.5 }
}
transition={{ duration: 0.15 }}
className="grid place-items-center"
>
{copied ? (
<FiCheck size={17} aria-hidden="true" />
) : (
<FiLink size={17} aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</motion.button>
</motion.li>
{/* Native share sheet (mobile, some desktops) */}
{canNativeShare && (
<motion.li variants={itemVariants}>
<motion.button
type="button"
onClick={handleNativeShare}
aria-label="More sharing options"
title="More"
whileHover={lift}
whileTap={{ scale: 0.9 }}
style={brand("#404040")}
className={iconBtn}
>
<FiMoreHorizontal size={17} aria-hidden="true" />
</motion.button>
</motion.li>
)}
{/* Close */}
<motion.li variants={itemVariants}>
<motion.button
type="button"
onClick={() => closeMenu(true)}
aria-label="Close share menu"
whileTap={{ scale: 0.9 }}
initial={false}
animate={reduce ? undefined : { rotate: 0 }}
whileHover={reduce ? undefined : { rotate: 90 }}
style={brand("#171717")}
className={iconBtn}
>
<FiX size={17} aria-hidden="true" />
</motion.button>
</motion.li>
</motion.ul>
)}
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{copied ? "Link copied" : ""}
</span>
</motion.div>
);
}
// Component Source: https://codekk.dev/Component details
Overview, install command, dependencies, and project links.
An animated share button for React built with TypeScript, Motion, React Icons, and Tailwind CSS v4. It starts as a compact "Share" pill. On click, it stretches open with a spring layout animation and the share targets pop in one after another. Each target lifts slightly on hover and fills with its own brand color, using a CSS variable per button so one class handles every network. It supports X, LinkedIn, Facebook, WhatsApp, Telegram, Reddit, and email, and the networks prop lets you choose which ones appear and in what order. The copy link button copies the URL to the clipboard and swaps its link icon for a green check mark with a quick rotate animation. It falls back to a hidden textarea copy on non-secure http:// pages where the Clipboard API is blocked. A "More" button opens the device's native share sheet, and it only appears on browsers that support navigator.share, so desktop users never see a button that does nothing. The close button spins a quarter turn on hover. The page URL and title are read when the button is clicked rather than on render, so the component is safe to server-render in Next.js. When the menu opens, focus moves to the first share target. Pressing Escape or the close button returns focus to the trigger, and clicking outside closes the menu. A polite live region announces "Link copied" to screen readers, and every icon button has an accessible label. Dark mode styles are included, and when prefers-reduced-motion is enabled, the morph, lift, and spin animations are turned off. It works in Vite, Create React App, and Next.js.
Install
npm install motion react-iconsDependencies
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
Similar Components
More from the same category




