ComponentsCreative

Hover Preview

Link preview card on hover for React. Show images, descriptions, and metadata on link hover. Perfect for portfolios, blogs, and documentation sites.

Last updated on

Edit on GitHub

Basic Usage

import {
  HoverPreviewLink,
  HoverPreviewProvider,
} from "@/components/ui/hover-preview";
 
const previewData = {
  midjourney: {
    image:
      "https://images.unsplash.com/photo-1695144244472-a4543101ef35?w=560&h=320&fit=crop",
    title: "Midjourney",
    subtitle: "Create stunning AI-generated artwork",
  },
  stable: {
    image:
      "https://images.unsplash.com/photo-1712002641088-9d76f9080889?w=560&h=320&fit=crop",
    title: "Stable Diffusion",
    subtitle: "Open-source generative AI model",
  },
  leonardo: {
    image:
      "https://images.unsplash.com/photo-1718241905696-cb34c2c07bed?w=560&h=320&fit=crop",
    title: "Leonardo AI",
    subtitle: "Production-ready creative assets",
  },
};
 
export function HoverPreviewDemo() {
  return (
    <HoverPreviewProvider data={previewData} className="p-8">
      <p className="max-w-2xl text-lg text-muted-foreground leading-relaxed">
        Explore{" "}
        <HoverPreviewLink previewKey="midjourney">Midjourney</HoverPreviewLink>{" "}
        for breathtaking AI-generated artwork and illustrations. For open-source
        freedom try{" "}
        <HoverPreviewLink previewKey="stable">
          Stable Diffusion
        </HoverPreviewLink>{" "}
        or generate production assets with{" "}
        <HoverPreviewLink previewKey="leonardo">Leonardo AI</HoverPreviewLink>.
      </p>
    </HoverPreviewProvider>
  );
}

Perfect for showcasing projects in a portfolio or case studies.

import {
  HoverPreviewLink,
  HoverPreviewProvider,
} from "@/components/ui/hover-preview";
 
const portfolioData = {
  project1: {
    image:
      "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=560&h=320&fit=crop",
    title: "E-Commerce Platform",
    subtitle: "Full-stack Next.js application with Stripe integration",
  },
  project2: {
    image:
      "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=560&h=320&fit=crop",
    title: "Analytics Dashboard",
    subtitle: "Real-time data visualization with D3.js",
  },
  project3: {
    image:
      "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=560&h=320&fit=crop",
    title: "Developer Tools",
    subtitle: "CLI utilities and VS Code extensions",
  },
  project4: {
    image:
      "https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=560&h=320&fit=crop",
    title: "Mobile App",
    subtitle: "Cross-platform React Native application",
  },
};
 
export function HoverPreviewPortfolioDemo() {
  return (
    <HoverPreviewProvider data={portfolioData} className="p-8">
      <div className="max-w-2xl space-y-4">
        <h2 className="font-bold text-2xl text-foreground">
          Featured Projects
        </h2>
        <p className="text-lg text-muted-foreground leading-relaxed">
          Check out my recent work including the{" "}
          <HoverPreviewLink previewKey="project1">
            E-Commerce Platform
          </HoverPreviewLink>
          , a comprehensive{" "}
          <HoverPreviewLink previewKey="project2">
            Analytics Dashboard
          </HoverPreviewLink>
          , various{" "}
          <HoverPreviewLink previewKey="project3">
            Developer Tools
          </HoverPreviewLink>
          , and a cross-platform{" "}
          <HoverPreviewLink previewKey="project4">Mobile App</HoverPreviewLink>.
        </p>
      </div>
    </HoverPreviewProvider>
  );
}

Product List

Display product previews in e-commerce or catalog pages.

import {
  HoverPreviewLink,
  HoverPreviewProvider,
} from "@/components/ui/hover-preview";
 
const productData = {
  laptop: {
    image:
      "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=560&h=320&fit=crop",
    title: "MacBook Pro",
    subtitle: "Apple M3 Pro chip, 18GB RAM, 512GB SSD",
  },
  headphones: {
    image:
      "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=560&h=320&fit=crop",
    title: "Sony WH-1000XM5",
    subtitle: "Industry-leading noise cancellation",
  },
  camera: {
    image:
      "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=560&h=320&fit=crop",
    title: "Sony A7 IV",
    subtitle: "Full-frame mirrorless camera",
  },
  watch: {
    image:
      "https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=560&h=320&fit=crop",
    title: "Apple Watch Ultra",
    subtitle: "The most rugged Apple Watch ever",
  },
};
 
export function HoverPreviewProductDemo() {
  return (
    <HoverPreviewProvider data={productData} className="p-8">
      <div className="max-w-3xl">
        <h2 className="mb-6 font-bold text-2xl text-foreground">
          Top Picks This Week
        </h2>
        <ul className="space-y-3 text-lg text-muted-foreground">
          <li className="flex items-center gap-2">
            <span className="text-primary">→</span>
            <HoverPreviewLink previewKey="laptop">MacBook Pro</HoverPreviewLink>
            <span className="text-sm">- Starting at $1,999</span>
          </li>
          <li className="flex items-center gap-2">
            <span className="text-primary">→</span>
            <HoverPreviewLink previewKey="headphones">
              Sony WH-1000XM5
            </HoverPreviewLink>
            <span className="text-sm">- $349</span>
          </li>
          <li className="flex items-center gap-2">
            <span className="text-primary">→</span>
            <HoverPreviewLink previewKey="camera">Sony A7 IV</HoverPreviewLink>
            <span className="text-sm">- $2,498</span>
          </li>
          <li className="flex items-center gap-2">
            <span className="text-primary">→</span>
            <HoverPreviewLink previewKey="watch">
              Apple Watch Ultra
            </HoverPreviewLink>
            <span className="text-sm">- $799</span>
          </li>
        </ul>
      </div>
    </HoverPreviewProvider>
  );
}

Custom Styling

Customize card appearance, cursor offset, and link styles.

import {
  HoverPreviewLink,
  HoverPreviewProvider,
} from "@/components/ui/hover-preview";
 
const teamData = {
  alex: {
    image:
      "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=560&h=320&fit=crop",
    title: "Alex Johnson",
    subtitle: "CEO & Founder",
  },
  sarah: {
    image:
      "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=560&h=320&fit=crop",
    title: "Sarah Miller",
    subtitle: "Head of Design",
  },
  michael: {
    image:
      "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=560&h=320&fit=crop",
    title: "Michael Chen",
    subtitle: "Lead Engineer",
  },
};
 
export function HoverPreviewCustomDemo() {
  return (
    <HoverPreviewProvider
      data={teamData}
      cardProps={{
        width: 350,
        borderRadius: 24,
        className: "bg-gradient-to-br from-card to-card/80",
      }}
      cursorOffset={30}
      className="p-8"
    >
      <div className="max-w-2xl space-y-4">
        <h2 className="font-bold text-2xl text-foreground">Meet Our Team</h2>
        <p className="text-lg text-muted-foreground leading-relaxed">
          Our leadership team includes{" "}
          <HoverPreviewLink
            previewKey="alex"
            className="text-blue-500 hover:text-blue-400"
          >
            Alex Johnson
          </HoverPreviewLink>
          ,{" "}
          <HoverPreviewLink
            previewKey="sarah"
            className="text-purple-500 hover:text-purple-400"
          >
            Sarah Miller
          </HoverPreviewLink>
          , and{" "}
          <HoverPreviewLink
            previewKey="michael"
            className="text-green-500 hover:text-green-400"
          >
            Michael Chen
          </HoverPreviewLink>
          .
        </p>
      </div>
    </HoverPreviewProvider>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/hover-preview"

Manual

Copy and paste the following code into your project. component/ui/hover-preview.tsx

"use client";
 
import NextImage from "next/image";
import type React from "react";
import {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/cn";
 
// ==========================================
// TYPES & INTERFACES
// ==========================================
 
export interface PreviewData {
  /** Image URL for the preview card */
  image: string;
  /** Title displayed in the preview card */
  title: string;
  /** Subtitle or description displayed below the title */
  subtitle?: string;
}
 
export interface HoverPreviewLinkProps {
  /** Unique key to identify which preview data to show */
  previewKey: string;
  /** Content to render as the hoverable link */
  children: React.ReactNode;
  /** Additional CSS classes for the link */
  className?: string;
}
 
export interface HoverPreviewCardProps {
  /** Width of the preview card in pixels */
  width?: number;
  /** Border radius of the card */
  borderRadius?: number;
  /** Additional CSS classes for the card */
  className?: string;
}
 
export interface HoverPreviewProviderProps {
  /** Preview data object with keys matching the previewKey in HoverPreviewLink */
  data: Record<string, PreviewData>;
  /** Children components (should include HoverPreviewLink components) */
  children: React.ReactNode;
  /** Card configuration options */
  cardProps?: HoverPreviewCardProps;
  /** Offset distance from cursor in pixels */
  cursorOffset?: number;
  /** Whether to preload all images on mount */
  preloadImages?: boolean;
  /** Additional CSS classes for the container */
  className?: string;
}
 
// ==========================================
// CONTEXT
// ==========================================
 
interface HoverPreviewContextValue {
  data: Record<string, PreviewData>;
  activePreview: PreviewData | null;
  position: { x: number; y: number };
  isVisible: boolean;
  cardProps: HoverPreviewCardProps;
  handleHoverStart: (key: string, e: React.MouseEvent) => void;
  handleHoverMove: (e: React.MouseEvent) => void;
  handleHoverEnd: () => void;
}
 
const HoverPreviewContext = createContext<HoverPreviewContextValue | null>(
  null,
);
 
function useHoverPreview() {
  const context = useContext(HoverPreviewContext);
  if (!context) {
    throw new Error(
      "HoverPreviewLink must be used within a HoverPreviewProvider",
    );
  }
  return context;
}
 
// ==========================================
// COMPONENTS
// ==========================================
 
export function HoverPreviewProvider({
  data,
  children,
  cardProps = {},
  cursorOffset = 20,
  preloadImages = true,
  className,
}: HoverPreviewProviderProps) {
  const [activePreview, setActivePreview] = useState<PreviewData | null>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isVisible, setIsVisible] = useState(false);
  const cardRef = useRef<HTMLDivElement>(null);
 
  const cardWidth = cardProps.width ?? 300;
  const cardHeight = 250;
 
  // Preload all images on mount
  useEffect(() => {
    if (!preloadImages) return;
    Object.values(data).forEach((item) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.src = item.image;
    });
  }, [data, preloadImages]);
 
  const updatePosition = useCallback(
    (e: React.MouseEvent | MouseEvent) => {
      let x = e.clientX - cardWidth / 2;
      let y = e.clientY - cardHeight - cursorOffset;
 
      // Boundary checks
      if (x + cardWidth > window.innerWidth - 20) {
        x = window.innerWidth - cardWidth - 20;
      }
      if (x < 20) {
        x = 20;
      }
      if (y < 20) {
        y = e.clientY + cursorOffset;
      }
 
      setPosition({ x, y });
    },
    [cardWidth, cardHeight, cursorOffset],
  );
 
  const handleHoverStart = useCallback(
    (key: string, e: React.MouseEvent) => {
      const previewData = data[key];
      if (previewData) {
        setActivePreview(previewData);
        setIsVisible(true);
        updatePosition(e);
      }
    },
    [data, updatePosition],
  );
 
  const handleHoverMove = useCallback(
    (e: React.MouseEvent) => {
      if (isVisible) {
        updatePosition(e);
      }
    },
    [isVisible, updatePosition],
  );
 
  const handleHoverEnd = useCallback(() => {
    setIsVisible(false);
  }, []);
 
  const contextValue: HoverPreviewContextValue = {
    data,
    activePreview,
    position,
    isVisible,
    cardProps: { width: cardWidth, ...cardProps },
    handleHoverStart,
    handleHoverMove,
    handleHoverEnd,
  };
 
  return (
    <HoverPreviewContext.Provider value={contextValue}>
      <div className={cn("relative", className)}>
        {children}
        <HoverPreviewCard ref={cardRef} />
      </div>
    </HoverPreviewContext.Provider>
  );
}
 
export function HoverPreviewLink({
  previewKey,
  children,
  className,
}: HoverPreviewLinkProps) {
  const { handleHoverStart, handleHoverMove, handleHoverEnd } =
    useHoverPreview();
 
  return (
    <span
      role="button"
      tabIndex={0}
      className={cn(
        "relative inline-block cursor-pointer font-semibold text-foreground transition-colors",
        "after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-gradient-to-r after:from-primary after:to-primary/60 after:transition-all after:duration-300",
        "hover:after:w-full",
        className,
      )}
      onMouseEnter={(e) => handleHoverStart(previewKey, e)}
      onMouseMove={handleHoverMove}
      onMouseLeave={handleHoverEnd}
      onFocus={(e) =>
        handleHoverStart(previewKey, e as unknown as React.MouseEvent)
      }
    >
      {children}
    </span>
  );
}
 
const HoverPreviewCard = forwardRef<HTMLDivElement>((_, ref) => {
  const { activePreview, position, isVisible, cardProps } = useHoverPreview();
 
  if (!activePreview) return null;
 
  return (
    <div
      ref={ref}
      className={cn(
        "pointer-events-none fixed z-50 transition-all duration-200",
        isVisible
          ? "scale-100 opacity-100"
          : "translate-y-2 scale-95 opacity-0",
      )}
      style={{
        left: `${position.x}px`,
        top: `${position.y}px`,
        width: cardProps.width,
      }}
    >
      <div
        className={cn(
          "overflow-hidden border border-border/50 bg-card/95 p-2 shadow-2xl backdrop-blur-md",
          cardProps.className,
        )}
        style={{ borderRadius: cardProps.borderRadius ?? 16 }}
      >
        <NextImage
          src={activePreview.image}
          alt={activePreview.title || ""}
          width={300}
          height={169}
          unoptimized
          className="aspect-video w-full rounded-lg object-cover"
        />
        <div className="px-2 pt-3 pb-1">
          <div className="font-semibold text-foreground text-sm">
            {activePreview.title}
          </div>
          {activePreview.subtitle && (
            <div className="mt-1 text-muted-foreground text-xs">
              {activePreview.subtitle}
            </div>
          )}
        </div>
      </div>
    </div>
  );
});
 
HoverPreviewCard.displayName = "HoverPreviewCard";
 
// ==========================================
// EXPORTS
// ==========================================
 
export { HoverPreviewContext, useHoverPreview };

Usage

import {
  HoverPreviewProvider,
  HoverPreviewLink,
} from "@/components/ui/hover-preview"

const previewData = {
  project1: {
    image: "https://example.com/image.jpg",
    title: "Project Title",
    subtitle: "Project description",
  },
  // Add more items...
}

export default function MyComponent() {
  return (
    <HoverPreviewProvider data={previewData}>
      <p>
        Check out my <HoverPreviewLink previewKey="project1">awesome project</HoverPreviewLink>.
      </p>
    </HoverPreviewProvider>
  )
}

API Reference

HoverPreviewProvider

Prop

Type

Prop

Type

PreviewData

Prop

Type

HoverPreviewCardProps

Prop

Type

Notes

  • Uses React Context to share hover state between provider and links
  • Preview card follows cursor with smart boundary detection
  • Cards automatically reposition to stay within viewport
  • Images are preloaded by default for instant display
  • Supports custom styling for both links and preview cards
  • Works with any number of hoverable links within a provider
  • Accessible - links maintain proper focus states and semantics

How is this guide?

On this page