Link Preview

PreviousNext

An interactive link component with rich preview tooltip showing website metadata like title, description, and images.

Docs
gaiaui

Preview

Loading preview…
registry/new-york/ui/link-preview.tsx
"use client";

import { Globe02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import Image from "next/image";
import { ReactNode, useEffect, useRef, useState } from "react";

import {
	Tooltip,
	TooltipContent,
	TooltipTrigger,
} from "@/components/ui/tooltip";

interface UrlMetadata {
	title: string | null;
	description: string | null;
	favicon: string | null;
	website_name: string | null;
	website_image: string | null;
	url: string;
}

interface LinkPreviewProps {
	href: string;
	children: ReactNode | string | null;
	className?: string;
}

const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);

const isValidHttpUrl = (str: string): boolean => {
	try {
		const url = new URL(str);
		return /^(http|https):$/.test(url.protocol);
	} catch {
		return false;
	}
};

export function LinkPreview({
	href,
	children,
	className = "cursor-pointer rounded-sm bg-primary/20 px-1 text-sm font-medium text-primary transition-all hover:text-white hover:underline",
}: LinkPreviewProps) {
	const elementRef = useRef<HTMLAnchorElement>(null);
	const [isInView, setIsInView] = useState(false);
	const [validFavicon, setValidFavicon] = useState(true);
	const [validImage, setValidImage] = useState(true);
	const [metadata, setMetadata] = useState<UrlMetadata | null>(null);
	const [isLoading, setIsLoading] = useState(false);
	const [error, setError] = useState<Error | null>(null);

	const isValidUrl =
		href &&
		isValidHttpUrl(href) &&
		!isEmail(href) &&
		!href.startsWith("mailto:");

	// Fetch metadata when in view
	useEffect(() => {
		if (!isInView || !isValidUrl || metadata) return;

		let isMounted = true;

		async function fetchMetadata() {
			setIsLoading(true);
			setError(null);

			try {
				// Fetch the URL directly
				const response = await fetch(href);

				if (!response.ok) {
					throw new Error("Failed to fetch URL");
				}

				const html = await response.text();

				// Extract metadata from HTML
				const getMetaTag = (name: string): string | null => {
					const patterns = [
						new RegExp(
							`<meta[^>]*property=["']${name}["'][^>]*content=["']([^"']*)["']`,
							"i",
						),
						new RegExp(
							`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']*)["']`,
							"i",
						),
						new RegExp(
							`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']${name}["']`,
							"i",
						),
						new RegExp(
							`<meta[^>]*content=["']([^"']*)["'][^>]*name=["']${name}["']`,
							"i",
						),
					];

					for (const pattern of patterns) {
						const match = html.match(pattern);
						if (match) return match[1];
					}
					return null;
				};

				const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
				const urlObj = new URL(href);

				const data: UrlMetadata = {
					title:
						getMetaTag("og:title") ||
						getMetaTag("twitter:title") ||
						titleMatch?.[1] ||
						null,
					description:
						getMetaTag("og:description") ||
						getMetaTag("twitter:description") ||
						getMetaTag("description") ||
						null,
					website_image:
						getMetaTag("og:image") || getMetaTag("twitter:image") || null,
					favicon:
						getMetaTag("icon") ||
						getMetaTag("shortcut icon") ||
						`${urlObj.origin}/favicon.ico`,
					website_name: getMetaTag("og:site_name") || urlObj.hostname,
					url: href,
				};

				if (isMounted) {
					setMetadata(data);
					setIsLoading(false);
				}
			} catch (err) {
				if (isMounted) {
					setError(err as Error);
					setIsLoading(false);
				}
			}
		}

		fetchMetadata();

		return () => {
			isMounted = false;
		};
	}, [isInView, isValidUrl, href, metadata]);

	// Set up intersection observer to detect when element is in view
	useEffect(() => {
		const element = elementRef.current;
		if (!element || !href) return;

		const observer = new IntersectionObserver(
			([entry]) => {
				if (entry.isIntersecting) {
					setIsInView(true);
					observer.unobserve(element);
				}
			},
			{
				rootMargin: "100px", // Start fetching 100px before element comes into view
				threshold: 0.1,
			},
		);

		observer.observe(element);

		return () => {
			observer.unobserve(element);
		};
	}, [href]);

	if (!href) return null;

	return (
		<Tooltip>
			<TooltipTrigger asChild>
				<a
					ref={elementRef}
					href={href}
					className={className}
					rel="noopener noreferrer"
					target="_blank"
				>
					{children}
				</a>
			</TooltipTrigger>
			<TooltipContent className="max-w-[280px] border border-zinc-700 bg-zinc-900 p-3 text-white shadow-lg">
				{isLoading ? (
					<div className="flex justify-center p-5">
						<div className="size-5 animate-spin rounded-full border-2 border-zinc-700 border-t-white" />
					</div>
				) : error || !isValidUrl ? (
					<div className="flex items-center gap-2 p-3 text-red-400">
						<HugeiconsIcon icon={Globe02Icon} size={16} />
						<span className="text-sm">
							{!isValidUrl ? "Invalid URL" : "Failed to load preview"}
						</span>
					</div>
				) : metadata ? (
					<div className="flex w-full flex-col gap-2">
						{/* Website Image */}
						{metadata.website_image && validImage && (
							<div className="relative aspect-video w-full overflow-hidden rounded-lg">
								<Image
									src={metadata.website_image}
									alt="Website preview"
									fill
									className="rounded-lg object-cover"
									onError={() => setValidImage(false)}
								/>
							</div>
						)}

						{/* Website Name & Favicon */}
						{(metadata.website_name || (metadata.favicon && validFavicon)) && (
							<div className="flex items-center gap-2">
								{metadata.favicon && validFavicon ? (
									<Image
										width={20}
										height={20}
										alt="Favicon"
										className="size-5 rounded-full"
										src={metadata.favicon}
										onError={() => setValidFavicon(false)}
									/>
								) : (
									<HugeiconsIcon
										icon={Globe02Icon}
										size={20}
										className="text-gray-400"
									/>
								)}
								{metadata.website_name && (
									<div className="truncate text-sm font-semibold">
										{metadata.website_name}
									</div>
								)}
							</div>
						)}

						{/* Title */}
						{metadata.title && (
							<div className="truncate text-sm font-medium text-white">
								{metadata.title}
							</div>
						)}

						{/* Description */}
						{metadata.description && (
							<div className="line-clamp-3 text-xs text-gray-400 w-full">
								{metadata.description}
							</div>
						)}

						{/* URL Link */}
						<div className="truncate text-xs text-primary">
							{href.replace("https://", "").replace("http://", "")}
						</div>
					</div>
				) : (
					<div className="flex items-center gap-2 p-3">
						<HugeiconsIcon
							icon={Globe02Icon}
							size={16}
							className="text-gray-400"
						/>
						<span className="text-sm text-gray-400">No preview available</span>
					</div>
				)}
			</TooltipContent>
		</Tooltip>
	);
}

Installation

npx shadcn@latest add @gaia/link-preview

Usage

import { LinkPreview } from "@/components/ui/link-preview"
<LinkPreview />