Components
Text Highlighter Sharer
A component that wraps content and displays a popover with sharing options when a user selects a snippet of text.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
- This component relies on the
Popover
andTooltip
components fromshadcn/ui
. Ensure you have them installed in your project. If not, you can add them using theshadcn/ui
CLI:
npx shadcn@latest add popover tooltip
pnpm dlx shadcn@latest add popover tooltip
yarn shadcn@latest add popover tooltip
- Add the following animation styles to your
globals.css
file:
@layer utilities {
@keyframes popover-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes popover-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
}
.animate-popover-in {
animation: popover-in 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-popover-out {
animation: popover-out 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
}
- Copy and paste the following code into your project.
'use client';
import * as React from 'react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Twitter,
Linkedin,
Facebook,
Copy as CopyIcon,
Share2,
MessageCircle,
Check,
} from 'lucide-react';
// Types
type PredefinedPlatformKey = 'twitter' | 'linkedin' | 'facebook' | 'whatsapp';
type PlatformKey = PredefinedPlatformKey | 'copy';
export type CustomPlatformConfig = {
name: string;
shareUrl: (text: string, url: string) => string;
icon?: React.ReactNode;
};
type SocialPlatform = PredefinedPlatformKey | CustomPlatformConfig;
interface TextHighlighterSharerProps {
/** The content within which text selection will be active. */
children: React.ReactNode;
/** The canonical URL of the page, used for sharing links. */
articleUrl: string;
/**
* Array of social platforms to offer. Can be predefined keys or custom configs.
* @default ['twitter', 'linkedin', 'facebook']
*/
socialPlatforms?: SocialPlatform[];
/**
* Minimum character length of selected text to trigger the popover.
* @default 20
*/
minSelectionLength?: number;
/** Optional callback when a share action is initiated. */
onShare?: (
platform: string,
selectedText: string,
shareableUrl: string
) => void;
/** Optional callback when text is copied. */
onCopy?: (selectedText: string, copiedValue: string) => void;
/**
* Optional text to append to the shared/copied quote (e.g., "via MyBlog").
* @default ''
*/
quoteSuffix?: string;
/**
* Vertical offset for positioning the popover relative to the text selection.
* @default 10
*/
popupOffset?: number;
/** Optional map to provide custom ReactNode icons, overriding defaults. */
customIcons?: Partial<Record<PlatformKey, React.ReactNode>>;
}
const DEFAULT_MIN_SELECTION_LENGTH = 20;
const DEFAULT_POPUP_OFFSET = 10;
const DEFAULT_SOCIAL_PLATFORMS: PredefinedPlatformKey[] = [
'twitter',
'linkedin',
'facebook',
];
const useDebouncedCallback = <A extends any[]>(
callback: (...args: A) => void,
delay: number
) => {
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[]
);
return (...args: A) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => callback(...args), delay);
};
};
/**
* A component that wraps content and displays a popover with sharing options
* when a user selects a snippet of text.
*/
const TextHighlighterSharer: React.FC<TextHighlighterSharerProps> = ({
children,
articleUrl,
socialPlatforms = DEFAULT_SOCIAL_PLATFORMS,
minSelectionLength = DEFAULT_MIN_SELECTION_LENGTH,
onShare,
onCopy,
quoteSuffix = '',
popupOffset = DEFAULT_POPUP_OFFSET,
customIcons = {},
}) => {
const [selectedText, setSelectedText] = React.useState<string | null>(null);
const [popoverOpen, setPopoverOpen] = React.useState(false);
const [triggerPosition, setTriggerPosition] = React.useState<{
top: number;
left: number;
} | null>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isCopied, setIsCopied] = React.useState(false);
const ICONS: Record<PlatformKey | 'custom', React.ReactNode> = React.useMemo(
() => ({
twitter: customIcons.twitter || <Twitter className='h-4 w-4' />,
linkedin: customIcons.linkedin || <Linkedin className='h-4 w-4' />,
facebook: customIcons.facebook || <Facebook className='h-4 w-4' />,
whatsapp: customIcons.whatsapp || <MessageCircle className='h-4 w-4' />,
copy: customIcons.copy || <CopyIcon className='h-4 w-4' />,
custom: <Share2 className='h-4 w-4' />,
}),
[customIcons]
);
const shareUrlBuilders: Record<
PredefinedPlatformKey,
(text: string, url: string, suffix: string) => string
> = React.useMemo(
() => ({
twitter: (text, url, suffix) =>
`https://twitter.com/intent/tweet?text=${encodeURIComponent(
`"${text}"${suffix ? ` ${suffix}` : ''}`
)}&url=${encodeURIComponent(url)}`,
linkedin: (text, url, suffix) =>
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(
url
)}&summary=${encodeURIComponent(
`"${text}"${suffix ? ` ${suffix}` : ''}`
)}`,
facebook: (text, url, _suffix) =>
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
url
)}"e=${encodeURIComponent(text)}`,
whatsapp: (text, url, suffix) =>
`https://api.whatsapp.com/send?text=${encodeURIComponent(
`"${text}"${suffix ? ` ${suffix}` : ''} \n${url}`
)}`,
}),
[]
);
const debouncedSelectionHandler = useDebouncedCallback(() => {
if (!wrapperRef.current) return;
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
if (popoverOpen) setPopoverOpen(false);
return;
}
const selectedRange = selection.getRangeAt(0);
if (!wrapperRef.current.contains(selectedRange.commonAncestorContainer)) {
if (popoverOpen) setPopoverOpen(false);
return;
}
const text = selection.toString().trim();
if (text.length >= minSelectionLength) {
const rect = selectedRange.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
// Calculate position relative to the wrapper, not the document
const relativeTop =
rect.top - wrapperRect.top + wrapperRef.current.scrollTop;
const relativeLeft =
rect.left -
wrapperRect.left +
rect.width / 2 +
wrapperRef.current.scrollLeft;
if (text !== selectedText) setSelectedText(text);
setTriggerPosition({ top: relativeTop, left: relativeLeft });
setPopoverOpen(true);
} else {
if (popoverOpen) setPopoverOpen(false);
}
}, 200);
React.useEffect(() => {
document.addEventListener('selectionchange', debouncedSelectionHandler);
const handleScroll = () => popoverOpen && setPopoverOpen(false);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener(
'selectionchange',
debouncedSelectionHandler
);
document.removeEventListener('scroll', handleScroll, true);
};
}, [debouncedSelectionHandler, popoverOpen, selectedText]);
React.useEffect(() => {
if (!popoverOpen) setTimeout(() => setIsCopied(false), 200);
}, [popoverOpen]);
const handleShare = (platformName: string, constructedShareUrl: string) => {
if (!selectedText) return;
window.open(constructedShareUrl, '_blank', 'noopener,noreferrer');
onShare?.(platformName, selectedText, articleUrl);
setPopoverOpen(false);
};
const handleCopy = async () => {
if (!selectedText) return;
const textToCopy = `"${selectedText}"${
quoteSuffix ? ` ${quoteSuffix}` : ''
} - Read more: ${articleUrl}`;
try {
await navigator.clipboard.writeText(textToCopy);
onCopy?.(selectedText, textToCopy);
setIsCopied(true);
setTimeout(() => setPopoverOpen(false), 1500);
} catch (err) {
console.error('Failed to copy text: ', err);
setPopoverOpen(false);
}
};
const renderSocialButtons = () => {
return socialPlatforms.map((platform, index) => {
if (!selectedText) return null;
let platformName: string;
let constructedShareUrl: string;
let icon: React.ReactNode;
if (typeof platform === 'string') {
platformName = platform.charAt(0).toUpperCase() + platform.slice(1);
constructedShareUrl = shareUrlBuilders[platform](
selectedText,
articleUrl,
quoteSuffix
);
icon = ICONS[platform];
} else {
platformName = platform.name;
constructedShareUrl = platform.shareUrl(
`"${selectedText}"${quoteSuffix ? ` ${quoteSuffix}` : ''}`,
articleUrl
);
icon = platform.icon || ICONS.custom;
}
return (
<TooltipProvider key={`${platformName}-${index}`} delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => handleShare(platformName, constructedShareUrl)}
aria-label={`Share on ${platformName}`}
className='h-8 w-8 p-1.5'
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={5}>
<p>Share on {platformName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
};
return (
<div ref={wrapperRef} className='relative selection:bg-primary/30'>
{children}
{triggerPosition && (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<div
style={{
position: 'absolute',
top: `${triggerPosition.top}px`,
left: `${triggerPosition.left}px`,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</PopoverTrigger>
<PopoverContent
className='w-auto p-1 flex items-center gap-px data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out'
side='top'
align='center'
sideOffset={popupOffset}
collisionPadding={10}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{selectedText && renderSocialButtons()}
{selectedText && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleCopy}
aria-label='Copy quote'
className='h-8 w-8 p-1.5'
>
{isCopied ? (
<Check className='h-4 w-4 text-green-500' />
) : (
ICONS.copy
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={5}>
<p>{isCopied ? 'Copied!' : 'Copy quote'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</PopoverContent>
</Popover>
)}
</div>
);
};
export default TextHighlighterSharer;
Usage
Wrap any block of text with the component. You must provide a URL for the articleUrl
prop.
import TextHighlighterSharer, {
CustomPlatformConfig,
} from '@/components/ui/text-highlighter-sharer';
import { Mail } from 'lucide-react';
// Example of a custom platform configuration for email
const emailPlatform: CustomPlatformConfig = {
name: 'Email',
shareUrl: (text, url) =>
`mailto:?subject=Check out this quote&body=${encodeURIComponent(
`${text}\n\nRead more at: ${url}`
)}`,
icon: <Mail className='h-4 w-4' />,
};
export default function ArticlePage() {
return (
<article className='prose dark:prose-invert'>
<TextHighlighterSharer
articleUrl='https://example.com/my-article'
quoteSuffix='via My Awesome Blog'
socialPlatforms={[
'twitter',
'linkedin',
emailPlatform, // Custom platforms are easy to add
]}
>
<h1>The Importance of User Experience</h1>
<p>
User experience (UX) is crucial for the success of any digital
product. It encompasses all aspects of the end-user's interaction with
the company, its services, and its products. A well-designed UX can
lead to higher customer satisfaction and loyalty. Select this text to
see the sharing popover in action.
</p>
<blockquote>
Good design is actually a lot harder to notice than poor design, in
part because good designs fit our needs so well that the design is
invisible.
</blockquote>
</TextHighlighterSharer>
</article>
);
}
Props
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | (Required) | The content within which text selection will be active. |
articleUrl | string | (Required) | The canonical URL of the page, used for sharing links. |
socialPlatforms | SocialPlatform[] | ['twitter', 'linkedin', 'facebook'] | Array of social platforms to offer. Can be predefined keys or custom configs. |
minSelectionLength | number | 20 | Minimum character length of selected text to trigger the popover. |
onShare | (platform: string, selectedText: string, shareableUrl: string) => void | undefined | Optional callback when a share action is initiated. |
onCopy | (selectedText: string, copiedValue: string) => void | undefined | Optional callback when text is copied. |
quoteSuffix | string | '' | Optional text to append to the shared/copied quote (e.g., "via MyBlog"). |
popupOffset | number | 10 | Vertical offset for positioning the popover relative to the text selection. |
customIcons | Partial<Record<PlatformKey, React.ReactNode>> | undefined | Optional map to provide custom ReactNode icons, overriding defaults. |