Skip to content
Skip to Content
DocsHover Image
Hover Image

Hover Image

A stunning hover-based image preview component. When users hover over project titles, a smooth mouse-following thumbnail appears showcasing the corresponding image. Perfect for portfolios, project showcases, and creative agency websites.

Component preview

Shree Krishna

The Supreme Personality of Godhead

Radha Krishna

The Divine Couple

Divine Love

Eternal Bond

Shree Krishna
Radha Krishna
Divine Love

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/hover-img.json"

Install Manually

1

Install dependencies

npm install gsap
2

Copy the source code

Copy into components/block/hover-img.tsx

"use client"; import React, { useRef, useEffect } from "react"; import gsap from "gsap"; import "@/components/block/hover-img.css"; interface ProjectItem { title: string; label: string; imageSrc: string; } const defaultProjects: ProjectItem[] = [ { title: "Shree Krishna", label: "The Supreme Personality of Godhead", imageSrc: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/hover-img/hover-img-img01-alt.jpg", }, { title: "Radha Krishna", label: "The Divine Couple", imageSrc: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/hover-img/hover-img-img02.jpg", }, { title: "Divine Love", label: "Eternal Bond", imageSrc: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/hover-img/hover-img-img03.jpg", }, ]; interface HoverImgProps { projects?: ProjectItem[]; className?: string; isContained?: boolean; // New prop for grid previews compact?: boolean; // New prop for compact layout } export function HoverImg({ projects = defaultProjects, className, isContained = false, compact = false }: HoverImgProps) { const containerRef = useRef<HTMLDivElement>(null); const thumbnailRef = useRef<HTMLDivElement>(null); const xToRef = useRef<gsap.QuickToFunc | null>(null); const yToRef = useRef<gsap.QuickToFunc | null>(null); useEffect(() => { const projectThumbnail = thumbnailRef.current; const projectsContainer = containerRef.current?.querySelector( ".hover-img-projects" ) as HTMLElement | null; if (!projectThumbnail || !projectsContainer) return; const projectElements = gsap.utils.toArray( ".hover-img-project", projectsContainer ) as HTMLElement[]; const thumbnails = gsap.utils.toArray( ".hover-img-thumbnail", projectThumbnail ) as HTMLElement[]; gsap.set(projectThumbnail, { scale: 0, xPercent: -50, yPercent: -50 }); xToRef.current = gsap.quickTo(projectThumbnail, "x", { duration: 0.4, ease: "power3.out", }); yToRef.current = gsap.quickTo(projectThumbnail, "y", { duration: 0.4, ease: "power3.out", }); const handleMouseMove = (e: MouseEvent) => { let x = e.clientX; let y = e.clientY; if (isContained && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); x = e.clientX - rect.left; y = e.clientY - rect.top; } xToRef.current?.(x); yToRef.current?.(y); }; const handleMouseLeave = () => { gsap.to(projectThumbnail, { scale: 0, duration: 0.3, ease: "power2.out", overwrite: "auto", }); }; projectsContainer.addEventListener("mousemove", handleMouseMove); projectsContainer.addEventListener("mouseleave", handleMouseLeave); const projectListeners: Array<() => void> = []; projectElements.forEach((project, index) => { const handleMouseEnter = () => { gsap.to(projectThumbnail, { scale: 1, duration: 0.4, ease: "power2.out", overwrite: "auto", }); gsap.to(thumbnails, { yPercent: -100 * index, duration: 0.4, ease: "power2.out", overwrite: "auto", }); }; project.addEventListener("mouseenter", handleMouseEnter); projectListeners.push(() => project.removeEventListener("mouseenter", handleMouseEnter) ); }); return () => { projectsContainer.removeEventListener("mousemove", handleMouseMove); projectsContainer.removeEventListener("mouseleave", handleMouseLeave); projectListeners.forEach((cleanup) => cleanup()); }; }, [projects, isContained]); return ( <div className={`hover-img-container ${compact ? "hover-img-compact" : ""} ${className || ""}`} ref={containerRef}> <div className="hover-img-projects"> {projects.map((project, index) => ( <div className="hover-img-project" key={index}> <h2>{project.title}</h2> <p>{project.label}</p> </div> ))} </div> <div className="hover-img-thumbnail-wrapper" ref={thumbnailRef} style={isContained ? { position: "absolute" } : undefined} > {projects.map((project, index) => ( <div className="hover-img-thumbnail" key={index}> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={project.imageSrc} alt={project.title} /> </div> ))} </div> </div> ); } export default HoverImg;
3

Add the CSS file

Copy into components/block/hover-img.css

.hover-img-container { --hi-bg: #f2f2f2; --hi-text: #000000; --hi-text-muted: #666666; --hi-border: rgba(0, 0, 0, 0.15); font-family: "Raleway", "Inter", system-ui, sans-serif; min-height: 100vh; width: 100%; background: var(--hi-bg); color: var(--hi-text); display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; } /* Dark mode support */ .dark .hover-img-container, :root[class~="dark"] .hover-img-container { --hi-bg: #0a0a0a; --hi-text: #ffffff; --hi-text-muted: #999999; --hi-border: rgba(255, 255, 255, 0.2); } .hover-img-projects { display: flex; flex-direction: column; width: 100%; max-width: 1200px; } .hover-img-project { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 2rem 4rem; border-top: 1px solid var(--hi-border); cursor: pointer; transition: opacity 0.5s ease; } .hover-img-project:last-child { border-bottom: 1px solid var(--hi-border); } .hover-img-project h2 { font-size: 2.5rem; font-weight: 500; letter-spacing: -0.02em; transition: transform 0.5s ease; margin: 0; } .hover-img-project p { font-size: 1rem; font-weight: 400; color: var(--hi-text-muted); transition: transform 0.5s ease; margin: 0; } .hover-img-project:hover { opacity: 0.5; } .hover-img-project:hover h2 { transform: translateX(-15px); } .hover-img-project:hover p { transform: translateX(15px); } .hover-img-thumbnail-wrapper { position: fixed; width: 400px; height: 250px; display: flex; flex-direction: column; overflow: hidden; pointer-events: none; top: 0; left: 0; transform-origin: center center; z-index: 100; border-radius: 12px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3); } .hover-img-thumbnail { width: 100%; height: 100%; flex-shrink: 0; } .hover-img-thumbnail img { width: 100%; height: 100%; object-fit: cover; } /* Responsive */ @media (max-width: 1024px) { .hover-img-project { padding: 2rem 4rem; } .hover-img-project h2 { font-size: 2.5rem; } } @media (max-width: 768px) { .hover-img-project { padding: 1.5rem 2rem; } .hover-img-project h2 { font-size: 1.75rem; } .hover-img-project p { font-size: 0.875rem; } .hover-img-thumbnail-wrapper { display: none; } } .hover-img-compact { min-height: auto; padding: 0; } .hover-img-compact .hover-img-project { padding: 0.75rem 1rem; } .hover-img-compact .hover-img-project h2 { font-size: 1.125rem; } .hover-img-compact .hover-img-project p { font-size: 0.7rem; } .hover-img-compact .hover-img-thumbnail-wrapper { width: 160px; height: 100px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2); }

Default preview images are hosted by ObsidianUI. Replace the image URLs with your own assets for offline use.

Props

PropTypeDefaultDescription
projectsProjectItem[]Default projectsArray of project items with title, label, and imageSrc
classNamestring-Additional CSS classes for the container
isContainedbooleanfalsePosition the image preview inside the component container
compactbooleanfalseUse compact spacing, typography, and preview dimensions

ProjectItem Type

Code
interface ProjectItem { title: string; // Project title displayed on hover label: string; // Subtitle/category label imageSrc: string; // URL to the project image }
Last updated on