Web InnoventixFreeCode

Hover Caption Gallery

Original · free

A responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

byWeb InnoventixReact + Tailwind
hovercaptiongalleryimages
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/gallery-hover-caption.json
gallery-hover-caption.tsx
"use client";

import { useRef } from "react";
import { motion, useInView } from "motion/react";

type Shape = "portrait" | "landscape" | "square";

interface Tile {
  src: string;
  alt: string;
  title: string;
  category: string;
  shape: Shape;
}

const TILES: Tile[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Fog rolling over a pine ridge at first light",
    title: "Morning ridge",
    category: "Landscape",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Weathered fishing boats moored along a quiet harbour",
    title: "Coastal light",
    category: "Travel",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Overhead of a ceramic mug and folded linen on oak",
    title: "Studio 04",
    category: "Still life",
    shape: "square",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "Dew beaded on a single fern frond in shade",
    title: "Undergrowth",
    category: "Nature",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g24.webp",
    alt: "Sunlit corner of a minimalist concrete stairwell",
    title: "Grey hours",
    category: "Architecture",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g15.webp",
    alt: "Close portrait of a woman in soft window light",
    title: "Held still",
    category: "Portrait",
    shape: "square",
  },
  {
    src: "/img/gallery/g28.webp",
    alt: "Long dune shadows stretching across pale sand",
    title: "Drift",
    category: "Landscape",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g32.webp",
    alt: "Neon sign reflected in a rain-slicked city street",
    title: "After rain",
    category: "Urban",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Freshly cut citrus arranged on a marble slab",
    title: "Zest",
    category: "Food",
    shape: "square",
  },
  {
    src: "/img/gallery/g21.webp",
    alt: "Snow-dusted peak catching the last of the alpenglow",
    title: "Last light",
    category: "Landscape",
    shape: "portrait",
  },
];

const SHAPE_SPAN: Record<Shape, string> = {
  portrait: "row-span-2",
  landscape: "row-span-1",
  square: "row-span-1",
};

const SHAPE_RATIO: Record<Shape, string> = {
  portrait: "aspect-[4/5]",
  landscape: "aspect-[13/9]",
  square: "aspect-square",
};

export default function GalleryHoverCaption() {
  const gridRef = useRef<HTMLDivElement>(null);
  const inView = useInView(gridRef, { once: true, margin: "-80px" });

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 py-20 text-neutral-900 sm:py-28 dark:bg-neutral-950 dark:text-neutral-50">
      <style>{`
        @keyframes ghc-shimmer {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(120%); }
        }
        @media (prefers-reduced-motion: reduce) {
          .ghc-shimmer { animation: none !important; }
          .ghc-tile, .ghc-tile * { transition-duration: 0.001ms !important; }
        }
      `}</style>

      {/* ambient background accents */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 left-1/2 h-[28rem] w-[28rem] -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute bottom-0 right-0 h-80 w-80 translate-x-1/3 translate-y-1/3 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-700/15"
      />

      <div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
        <div className="mb-12 flex flex-col gap-4 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Selected works
            </span>
            <h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
              A field of light &amp; texture
            </h2>
            <p className="mt-3 text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
              Hover any frame to see it breathe. A curated set of stills across
              landscape, portrait, and quiet still life.
            </p>
          </div>
          <p className="shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-500">
            {TILES.length} frames
          </p>
        </div>

        <div
          ref={gridRef}
          className="grid auto-rows-[11rem] grid-cols-2 gap-3 sm:auto-rows-[13rem] sm:grid-cols-3 sm:gap-4 lg:auto-rows-[14rem] lg:grid-cols-4"
        >
          {TILES.map((tile, i) => (
            <motion.figure
              key={tile.src}
              initial={{ opacity: 0, y: 26 }}
              animate={inView ? { opacity: 1, y: 0 } : undefined}
              transition={{
                duration: 0.6,
                delay: i * 0.06,
                ease: [0.22, 1, 0.36, 1],
              }}
              tabIndex={0}
              aria-label={`${tile.title} — ${tile.category}`}
              className={`ghc-tile group relative overflow-hidden rounded-2xl bg-neutral-200 shadow-sm outline-none ring-1 ring-black/5 transition-shadow duration-500 hover:shadow-2xl hover:shadow-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-neutral-800 dark:ring-white/10 dark:focus-visible:ring-offset-neutral-950 ${SHAPE_SPAN[tile.shape]}`}
            >
              {/* zoom layer */}
              <div className="absolute inset-0 overflow-hidden rounded-2xl">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={tile.src}
                  alt={tile.alt}
                  loading="lazy"
                  draggable={false}
                  className={`h-full w-full scale-100 object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.08] group-focus-visible:scale-[1.08] ${SHAPE_RATIO[tile.shape]}`}
                />
              </div>

              {/* shimmer sweep on hover */}
              <span
                aria-hidden
                className="ghc-shimmer pointer-events-none absolute inset-y-0 -left-1/2 w-1/2 -skew-x-12 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 group-hover:opacity-100 group-hover:[animation:ghc-shimmer_1.1s_ease-out] dark:via-white/10"
              />

              {/* gradient scrim */}
              <div
                aria-hidden
                className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100"
              />

              {/* caption bar */}
              <figcaption className="absolute inset-x-0 bottom-0 flex translate-y-3 items-end justify-between gap-3 p-4 opacity-0 transition-all duration-500 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
                <span className="min-w-0">
                  <span className="block text-[0.65rem] font-semibold uppercase tracking-[0.2em] text-indigo-200">
                    {tile.category}
                  </span>
                  <span className="mt-0.5 block truncate text-base font-semibold text-white drop-shadow">
                    {tile.title}
                  </span>
                </span>
                <span
                  aria-hidden
                  className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/15 text-white ring-1 ring-white/25 backdrop-blur-sm transition-transform duration-500 group-hover:rotate-45"
                >
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    className="h-4 w-4"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <path d="M7 17 17 7M9 7h8v8" />
                  </svg>
                </span>
              </figcaption>
            </motion.figure>
          ))}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →
Masonry Gallery

Masonry Gallery

Original

A Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery

Filterable Gallery

Original

A filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery

Tilt Cards Gallery

Original

A grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery

Lightbox Gallery

Original

A thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery

Expanding Strip Gallery

Original

A horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery

Marquee Gallery

Original

Two rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery

Hover Zoom Overlay Gallery

Original

A grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery

Polaroid Stack Gallery

Original

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

Featured Thumbs Gallery

Featured Thumbs Gallery

Original

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery

Carousel Gallery

Original

An image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery

Duotone Hover Gallery

Original

A grid where images sit under a coloured duotone wash that clears to full colour on hover.

Flip Cards Gallery

Flip Cards Gallery

Original

A grid of cards that flip in 3D on hover to reveal a caption and details on the back.