Components

Text Highlighter Sharer

A component that wraps content and displays a popover with sharing options when a user selects a snippet of text.

Core Principles of User Experience

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 default sharing options.

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.
Research Paper Abstract

This paper explores the superposition and entanglement principles of quantum mechanics to propose a novel cryptographic algorithm. By leveraging the inherent probabilistic nature of qubits, we demonstrate a theoretically unbreakable encryption scheme. Our findings suggest that quantum computing could revolutionize secure communications. Highlight this text to share via LinkedIn, WhatsApp, or Email.

Startup Raises $50M Series B

Innovate Inc., a leader in AI-driven data analytics, announced today that it has closed a $50 million Series B funding round. The investment was led by Future Ventures with participation from existing investors. The company plans to use the funds to expand its engineering team and accelerate product development. You must select at least 50 characters for the popover to appear.

Installation

CLI

Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.

Manual

  1. This component relies on the Popover and Tooltip components from shadcn/ui. Ensure you have them installed in your project. If not, you can add them using the shadcn/ui CLI:
npx shadcn@latest add popover tooltip
pnpm dlx shadcn@latest add popover tooltip
yarn shadcn@latest add popover tooltip
  1. 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);
  }
}
  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
        )}&quote=${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

PropTypeDefaultDescription
childrenReact.ReactNode(Required)The content within which text selection will be active.
articleUrlstring(Required)The canonical URL of the page, used for sharing links.
socialPlatformsSocialPlatform[]['twitter', 'linkedin', 'facebook']Array of social platforms to offer. Can be predefined keys or custom configs.
minSelectionLengthnumber20Minimum character length of selected text to trigger the popover.
onShare(platform: string, selectedText: string, shareableUrl: string) => voidundefinedOptional callback when a share action is initiated.
onCopy(selectedText: string, copiedValue: string) => voidundefinedOptional callback when text is copied.
quoteSuffixstring''Optional text to append to the shared/copied quote (e.g., "via MyBlog").
popupOffsetnumber10Vertical offset for positioning the popover relative to the text selection.
customIconsPartial<Record<PlatformKey, React.ReactNode>>undefinedOptional map to provide custom ReactNode icons, overriding defaults.