Hover Preview
Display contextual preview cards when hovering over links or text, perfect for portfolios, product showcases, and content-rich pages.
Last updated on
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>
)
}Portfolio Links
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
HoverPreviewLink
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?