Project 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

Emily

Portrait & Photography

Daisy

Editorial & Design

Lance

Brand Identity

Renei

Creative Direction

Roiin

Visual Storytelling

Lylia

Motion & Art

Linda

Digital Experience

Using CLI

npx dimaac add ProjectHoverSection

Manual Installation

npm install react @gsap/react

lib/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;