Getting Started
IntroductionLayout Components
Expandable PanelProject Hover Section
A project list where hovering over each row reveals a floating thumbnail that follows your mouse. Desktop: GSAP-animated thumbnail with slider for multiple projects. Mobile: tap-to-expand accordion with inline images.
React
Tailwind CSS
GSAP
Interactive
Projects
Hover
Bruno
Keeper of the Woods
Chompy
Jaws of the Marsh
Snap
Master of the Lagoon
Crowley
Messenger of the Skies
Foxy
Cunning and Quick
Gori
Strength and Wisdom
Hoot
Eyes of the Night
Slither
Ancient Wisdom
Rajah
Stripes of Power
Tank
Street Enforcer
Rusty
Smooth Operator
Blaze
Street King
Using CLI
npx dimaac add ProjectHoverSectionManual Installation
npm install react @gsap/reactlib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
} components/ui/interactive/ProjectHoverSection.tsx
'use client';
import React, { useRef, useState, useEffect } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';
import Image from 'next/image';
import { cn } from '@/lib/utils';
export interface ProjectItem {
title: string;
subtitle: string;
image: string;
alt?: string;
}
interface ProjectHoverSectionProps {
projects: ProjectItem[];
className?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
}
const ProjectHoverSection: React.FC<ProjectHoverSectionProps> = ({
projects,
className,
thumbnailWidth = 250,
thumbnailHeight = 300,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const [isDesktop, setIsDesktop] = useState(true);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [modal, setModal] = useState<{ active: boolean; index: number }>({ active: false, index: 0 });
// Detect desktop vs mobile
useEffect(() => {
const checkDesktop = () => setIsDesktop(window.innerWidth >= 768);
checkDesktop();
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
}, []);
// GSAP: position follows mouse relative to container
useGSAP(
() => {
if (!isDesktop || !thumbnailRef.current || !sliderRef.current || !containerRef.current) return;
gsap.set(thumbnailRef.current, {
scale: 0,
xPercent: -50,
yPercent: -50,
force3D: true,
});
gsap.set(sliderRef.current, { y: 0 });
const xTo = gsap.quickTo(thumbnailRef.current, 'x', { duration: 0.5, ease: 'power3.out' });
const yTo = gsap.quickTo(thumbnailRef.current, 'y', { duration: 0.5, ease: 'power3.out' });
// Track whether we've received a mouse position yet
let hasPosition = false;
const handleMouseMove = (e: MouseEvent) => {
const rect = containerRef.current!.getBoundingClientRect();
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
if (!hasPosition) {
// Jump immediately to mouse position on first move (no tweening)
gsap.set(thumbnailRef.current, { x: relX, y: relY });
hasPosition = true;
} else {
xTo(relX);
yTo(relY);
}
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
},
{ dependencies: [isDesktop] }
);
// GSAP: animate scale + slider when modal state changes
useGSAP(
() => {
if (!isDesktop || !thumbnailRef.current || !sliderRef.current) return;
if (modal.active) {
gsap.to(thumbnailRef.current, {
scale: 1,
opacity: 1,
visibility: 'visible',
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto',
});
gsap.to(sliderRef.current, {
y: -modal.index * thumbnailHeight,
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto',
});
} else {
gsap.to(thumbnailRef.current, {
scale: 0,
opacity: 0,
duration: 0.3,
ease: 'power2.in',
overwrite: 'auto',
onComplete: () => {
gsap.set(thumbnailRef.current, { visibility: 'hidden' });
},
});
}
},
{ dependencies: [modal.active, modal.index, isDesktop, thumbnailHeight, projects.length] }
);
if (isDesktop) {
return (
<div
ref={containerRef}
onMouseLeave={() => setModal({ active: false, index: 0 })}
className={cn(
'relative flex flex-col w-full max-w-[1000px] mx-auto py-12',
className
)}
>
<div className="flex flex-col w-full">
{projects.map((project, index) => (
<div
key={index}
onMouseEnter={() => setModal({ active: true, index })}
className={cn(
'w-full flex items-center justify-between px-6 md:px-16 py-8 md:py-12 border-t border-white/20 cursor-pointer transition-opacity duration-300',
modal.active && modal.index === index && 'opacity-60'
)}
>
<h2
className={cn(
'text-2xl md:text-4xl lg:text-5xl font-medium text-white transition-transform duration-500 ease-out',
modal.active && modal.index === index && '-translate-x-4'
)}
>
{project.title}
</h2>
<p
className={cn(
'text-sm md:text-base text-white/70 transition-transform duration-500 ease-out',
modal.active && modal.index === index && 'translate-x-4'
)}
>
{project.subtitle}
</p>
</div>
))}
{/* Bottom border */}
<div className="w-full h-px bg-white/20" />
</div>
{/* Thumbnail — absolute inside relative container, pointer-events-none */}
<div
ref={thumbnailRef}
className="absolute top-0 left-0 z-50 overflow-hidden rounded-lg border border-white/20 shadow-2xl"
style={{
width: thumbnailWidth,
height: thumbnailHeight,
pointerEvents: 'none',
opacity: 0,
visibility: 'hidden' as const,
}}
>
<div
ref={sliderRef}
className="relative w-full"
style={{
height: thumbnailHeight * projects.length,
pointerEvents: 'none',
}}
>
{projects.map((project, index) => (
<div
key={index}
className="absolute left-0 w-full flex items-center justify-center"
style={{
top: index * thumbnailHeight,
width: thumbnailWidth,
height: thumbnailHeight,
pointerEvents: 'none',
}}
>
<Image
src={project.image}
alt={project.alt ?? project.title}
width={thumbnailWidth}
height={thumbnailHeight}
className="w-full h-full object-cover object-top"
style={{ pointerEvents: 'none' }}
/>
</div>
))}
</div>
</div>
</div>
);
}
// Mobile: Clean stacked version with tap-to-expand
return (
<div className={cn('flex flex-col w-full max-w-[1000px] mx-auto py-6', className)}>
{projects.map((project, index) => (
<div
key={index}
className="border-b border-white/20 last:border-b-0"
>
<button
type="button"
onClick={() => setExpandedIndex(expandedIndex === index ? null : index)}
className="w-full flex items-center justify-between px-4 py-5 text-left active:opacity-80 transition-opacity"
>
<h2 className="text-xl font-medium text-white">{project.title}</h2>
<p className="text-sm text-white/70">{project.subtitle}</p>
<span className="ml-2 text-white/50 text-lg">
{expandedIndex === index ? '−' : '+'}
</span>
</button>
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-out',
expandedIndex === index ? 'max-h-64 opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className="px-4 pb-4">
<div className="relative w-full aspect-[4/3] rounded-lg overflow-hidden border border-white/20">
<Image
src={project.image}
alt={project.alt ?? project.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 400px"
/>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default ProjectHoverSection;










