Components

Hover Preview

Display contextual preview cards when hovering over links or text, perfect for portfolios, product showcases, and content-rich pages.

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 leading-relaxed text-muted-foreground">
        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="text-2xl font-bold text-foreground">Featured Projects</h2>
        <p className="text-lg leading-relaxed text-muted-foreground">
          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 text-2xl font-bold 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="text-2xl font-bold text-foreground">Meet Our Team</h2>
        <p className="text-lg leading-relaxed text-muted-foreground">
          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 { cn } from "@/lib/cn"
import React, { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react"
 
// ==========================================
// 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
      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}
    >
      {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" : "scale-95 opacity-0 translate-y-2"
      )}
      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 }}
      >
        <img
          src={activePreview.image}
          alt={activePreview.title || ""}
          crossOrigin="anonymous"
          referrerPolicy="no-referrer"
          className="aspect-video w-full rounded-lg object-cover"
        />
        <div className="px-2 pt-3 pb-1">
          <div className="text-sm font-semibold text-foreground">{activePreview.title}</div>
          {activePreview.subtitle && (
            <div className="mt-1 text-xs text-muted-foreground">{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