Social Share Buttons in React: Twitter, LinkedIn, Copy Link
Build Twitter, LinkedIn, and copy-link share buttons in React from scratch - no bloated libraries, just clean components with real UX polish.
Why Roll Your Own Instead of Using a Library
Every few months someone drops a new social share library into the React ecosystem, and every time I check the bundle size I want to cry. react-share - the most popular option - clocks in at around 40 kB minified just to open a URL in a new tab. You don't need that.
The actual mechanics of sharing to Twitter or LinkedIn are just URL schemes. Twitter's is https://twitter.com/intent/tweet?text=...&url=.... LinkedIn's is https://www.linkedin.com/sharing/share-offsite/?url=.... That's it. You're literally constructing a query string and calling window.open.
Honestly, building these from scratch takes maybe 20 minutes and gives you total control over styling, analytics hooks, and behavior. You can wire in your own click tracking, match your design system exactly, and not worry about a library author dropping support.
One more thing - most third-party share libraries inject inline styles or come with their own icon sets. If you're working with a component library that already has a consistent look (like the stuff you'd find when you browse components), that's friction you don't want.
Building the Twitter Share Button
Twitter (X, whatever you're calling it this week) uses an intent URL that accepts text, url, hashtags, and via params. You construct the URL, encode the values, and open it. The 2023 rebrand didn't change the intent URL - twitter.com/intent/tweet still works fine as of 2026.
Here's a minimal but complete component:
import { Twitter } from 'lucide-react';
interface TwitterShareProps {
url: string;
text?: string;
hashtags?: string[];
via?: string;
}
export function TwitterShareButton({ url, text, hashtags, via }: TwitterShareProps) {
const handleShare = () => {
const params = new URLSearchParams({
url,
...(text && { text }),
...(hashtags?.length && { hashtags: hashtags.join(',') }),
...(via && { via }),
});
window.open(
`https://twitter.com/intent/tweet?${params.toString()}`,
'_blank',
'width=550,height=420,noopener,noreferrer'
);
};
return (
<button
onClick={handleShare}
aria-label="Share on Twitter"
className="flex items-center gap-2 rounded-lg bg-black px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 transition-colors"
>
<Twitter size={16} />
Tweet
</button>
);
}Worth noting: passing noopener,noreferrer to window.open is a security thing. Without it, the opened tab can access your page's window object via window.opener. Always include both.
The width/height of 550x420 is the size Twitter's own share dialog is designed for. You can technically omit those, but users get a jarring full-tab open instead of a clean popup. Small detail, meaningful UX difference.
Building the LinkedIn Share Button
LinkedIn is even simpler than Twitter. Their share URL accepts a single url param - that's it. No text pre-fill, no hashtag injection. LinkedIn scrapes the OG tags from the URL you pass in, which means your og:title, og:description, and og:image meta tags actually matter here.
import { Linkedin } from 'lucide-react';
interface LinkedInShareProps {
url: string;
}
export function LinkedInShareButton({ url }: LinkedInShareProps) {
const handleShare = () => {
const shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
window.open(shareUrl, '_blank', 'width=600,height=600,noopener,noreferrer');
};
return (
<button
onClick={handleShare}
aria-label="Share on LinkedIn"
className="flex items-center gap-2 rounded-lg bg-[#0A66C2] px-4 py-2 text-sm font-medium text-white hover:bg-[#004182] transition-colors"
>
<Linkedin size={16} />
Share
</button>
);
}In practice, LinkedIn's share popup tends to open slowly - it loads a preview of your page inside the dialog. If you're sharing something that's behind auth or on localhost, the preview will just be blank. That's expected behavior, not a bug in your code.
Quick aside: LinkedIn deprecated their full Share API v1 back in 2021. Some tutorials still reference shareArticle from the old v1 endpoint - that no longer works. The share-offsite URL shown above is the current supported method for web shares.
The Copy Link Button (With Feedback State)
Copy to clipboard sounds trivial. It kind of is. But the UX detail that separates a good copy button from a bad one is the feedback - you need to tell the user the copy worked. Without it, people click again, wonder if it worked, click three more times.
The navigator.clipboard.writeText API is async and returns a Promise. Wrap it in try/catch, then flip a piece of state to show a checkmark or 'Copied!' label for about 2 seconds before resetting.
import { useState } from 'react';
import { Link, Check } from 'lucide-react';
interface CopyLinkButtonProps {
url?: string;
label?: string;
}
export function CopyLinkButton({ url, label = 'Copy link' }: CopyLinkButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const target = url ?? window.location.href;
try {
await navigator.clipboard.writeText(target);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API blocked - fall back to execCommand
const el = document.createElement('textarea');
el.value = target;
el.style.position = 'fixed';
el.style.opacity = '0';
document.body.appendChild(el);
el.focus();
el.select();
document.execCommand('copy');
document.body.removeChild(el);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<button
onClick={handleCopy}
aria-label={copied ? 'Link copied' : label}
className="flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800 transition-colors"
>
{copied ? <Check size={16} className="text-green-400" /> : <Link size={16} />}
{copied ? 'Copied!' : label}
</button>
);
}The execCommand fallback is technically deprecated since Chrome 86, but it still works in most browsers and fires when the Clipboard API is blocked (non-HTTPS, iframe sandboxes, old mobile WebViews). Keep it. You'll thank yourself when you get a Slack message saying share's broken in the app's embedded browser.
Look, the aria-label swap when copied is true matters for screen reader users. A sighted user sees the icon change; a screen reader user hears nothing if you forget to update the accessible label. Takes 30 seconds, good habit.
Composing a ShareBar Component
Now that you have three individual buttons, you want a ShareBar that composes them cleanly and works anywhere on a page - blog post footer, article header, sidebar widget, wherever.
import { TwitterShareButton } from './TwitterShareButton';
import { LinkedInShareButton } from './LinkedInShareButton';
import { CopyLinkButton } from './CopyLinkButton';
interface ShareBarProps {
url: string;
title?: string;
hashtags?: string[];
via?: string;
className?: string;
}
export function ShareBar({ url, title, hashtags, via, className }: ShareBarProps) {
return (
<div
role="group"
aria-label="Share this page"
className={`flex flex-wrap items-center gap-3 ${className ?? ''}`}
>
<span className="text-sm text-zinc-400">Share:</span>
<TwitterShareButton url={url} text={title} hashtags={hashtags} via={via} />
<LinkedInShareButton url={url} />
<CopyLinkButton url={url} />
</div>
);
}In a Next.js app, you'll usually want to call this with a canonical URL rather than relying on window.location.href from inside the child components. Pass the full URL explicitly - especially if you're rendering on the server side or have query strings that shouldn't appear in share previews.
That said, if this is purely client-side rendered, defaulting to window.location.href in CopyLinkButton when no url prop is passed is a nice convenience that covers 80% of use cases without forcing the parent to always pass a prop.
For visual styling inspiration - especially if you're going for a dark glassmorphic look - check out the glassmorphism components or use the glassmorphism generator to build a backdrop-filter style for the share bar container. Looks sharp on blog hero sections.
Adding Analytics and Tracking
Sharing buttons without tracking are fine for personal projects. In a product, you want to know what people share, from where, and how often. The cleanest way to wire this in is an onShare callback prop on each button - you call it after the action succeeds, and the consumer decides what to do with it.
// Add to TwitterShareButton and LinkedInShareButton props:
interface ShareButtonProps {
url: string;
onShare?: (platform: 'twitter' | 'linkedin' | 'copy') => void;
}
// Inside handleShare, after window.open():
onShare?.('twitter');
// Then in the page-level consumer:
<ShareBar
url="https://example.com/my-article"
onShare={(platform) => {
analytics.track('content_shared', {
platform,
path: window.location.pathname,
});
}}
/>This keeps the buttons themselves dumb and reusable. The analytics logic lives where it belongs - in the page or layout layer. You can swap from Google Analytics to Plausible to PostHog without touching the share components at all.
One more thing - if you're building a copy button as a standalone UI element (say, for code blocks rather than URL sharing), the pattern is identical but the trigger and feedback positioning might differ. Worth factoring them out separately if your use cases diverge.
Styling Options: Minimal, Pill, and Icon-Only
Three approaches work well in production. Minimal (what we built above) gives you text + icon on a flat background. Pill style rounds the buttons to border-radius: 9999px and adds a bit more horizontal padding. Icon-only drops the label entirely and relies on tooltip/aria-label for accessibility.
// Pill variant - just swap the className:
className="flex items-center gap-2 rounded-full px-5 py-2 text-sm font-medium ..."
// Icon-only variant (32x32 px circle):
className="grid h-8 w-8 place-items-center rounded-full bg-zinc-800 hover:bg-zinc-700 transition-colors"
// Remove the text label from JSX, keep aria-labelIcon-only buttons need a tooltip - either a native title attribute (accessible, but ugly) or a proper tooltip component. At minimum, 32px touch targets for mobile. Anything smaller and users are rage-tapping.
In practice, pill buttons test better on marketing pages where the share bar is a prominent feature. Icon-only works well in tight contexts - article sidebars, compact cards, mobile sticky footers. Text+icon is the safest default for new projects.
If you want to push the visual design further - neon glows, cyberpunk aesthetics, Y2K bold colors - both neobrutalism and cyberpunk style hubs have patterns that translate directly to button components. It's just CSS classes swapped on the same underlying JSX structure.
FAQ
Yes, always. Use encodeURIComponent(url) on any value you're passing as a query param. URLSearchParams handles this automatically if you use it, but raw string interpolation doesn't.
The Clipboard API requires a secure context (HTTPS or localhost) and user gesture. In iframes, some browsers also require the clipboard-write permissions policy. The execCommand fallback handles the edge cases.
No. LinkedIn's share-offsite URL only accepts a url param. LinkedIn reads your page's OG meta tags to populate the share preview - so set good og:title and og:description tags on the target page.
Not if called synchronously inside a click handler. Popup blockers only fire when window.open is called outside a user gesture (like from setTimeout or a Promise callback). Keep the call direct in your handler.