Web InnoventixFreeCode

Grayscale Hover Gallery

Original · free

Images shown in greyscale that turn to full colour with a slight zoom on hover.

byWeb InnoventixReact + Tailwind
grayscalehovergalleryimages
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-grayscale-hover.json
gallery-grayscale-hover.tsx
"use client";

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

type Shot = {
  src: string;
  alt: string;
  title: string;
  category: string;
  location: string;
  span: string;
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Fog rolling between dark pine ridges at dawn",
    title: "Ridge Line, First Light",
    category: "Landscape",
    location: "Cascade Range, OR",
    span: "sm:col-span-2 sm:row-span-2",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Portrait of a ceramicist holding a raw clay bowl",
    title: "Hands That Remember",
    category: "Portrait",
    location: "Studio 4, Lisbon",
    span: "sm:row-span-2",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Overhead view of a market table stacked with citrus",
    title: "Saturday Citrus",
    category: "Street",
    location: "Mercado Central",
    span: "",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "Rain-slick city crossing lit by neon signage at night",
    title: "Wet Neon",
    category: "Urban",
    location: "Shinjuku, 23:40",
    span: "",
  },
  {
    src: "/img/gallery/g22.webp",
    alt: "Close crop of woven textile with tight geometric pattern",
    title: "Warp & Weft",
    category: "Detail",
    location: "Oaxaca Loom Co.",
    span: "sm:row-span-2",
  },
  {
    src: "/img/gallery/g14.webp",
    alt: "Lone swimmer cutting across a still turquoise pool",
    title: "Fifty Meters, Alone",
    category: "Sport",
    location: "Municipal Baths",
    span: "sm:col-span-2",
  },
  {
    src: "/img/gallery/g28.webp",
    alt: "Vintage motorcycle parked against a weathered brick wall",
    title: "Idle Chrome",
    category: "Object",
    location: "Alley off 5th",
    span: "",
  },
  {
    src: "/img/gallery/g31.webp",
    alt: "Silhouetted couple walking a windswept dune at sunset",
    title: "The Long Way Back",
    category: "Documentary",
    location: "Outer Banks",
    span: "sm:col-span-2",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "Steam rising from espresso in a quiet corner cafe",
    title: "First Pour",
    category: "Still Life",
    location: "Café Nord",
    span: "",
  },
  {
    src: "/img/gallery/g25.webp",
    alt: "Architectural spiral staircase shot from directly below",
    title: "Descent",
    category: "Architecture",
    location: "Casa Milà",
    span: "",
  },
];

export default function GalleryGrayscaleHover() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });
  const [active, setActive] = useState<number | null>(null);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 py-20 sm:py-28 dark:bg-neutral-950">
      <style>{`
        @keyframes ggh-rise {
          0% { opacity: 0; transform: translateY(28px) scale(0.985); }
          100% { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes ggh-sheen {
          0% { transform: translateX(-120%) skewX(-18deg); }
          100% { transform: translateX(240%) skewX(-18deg); }
        }
        @keyframes ggh-dot {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 1; }
        }
        .ggh-card { will-change: transform, opacity; }
        .ggh-sheen { animation: ggh-sheen 0.9s cubic-bezier(0.22, 1, 0.36, 1); }
        @media (prefers-reduced-motion: reduce) {
          .ggh-card { animation: none !important; }
          .ggh-sheen { animation: none !important; }
          .ggh-dot { animation: none !important; }
          .ggh-img { transition: none !important; }
        }
      `}</style>

      {/* ambient background wash */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-70"
        style={{
          background:
            "radial-gradient(60% 50% at 15% 0%, rgba(99,102,241,0.10), transparent 60%), radial-gradient(50% 45% at 95% 100%, rgba(244,63,94,0.08), transparent 60%)",
        }}
      />

      <div ref={sectionRef} className="relative mx-auto max-w-6xl px-5 sm:px-8">
        {/* header */}
        <div className="mb-12 flex flex-col gap-6 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
          <motion.div
            initial={{ opacity: 0, y: 18 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
          >
            <span className="inline-flex items-center gap-2 rounded-full border border-neutral-300/70 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-neutral-600 backdrop-blur dark:border-neutral-700/70 dark:bg-neutral-900/70 dark:text-neutral-400">
              <span className="ggh-dot h-1.5 w-1.5 rounded-full bg-indigo-500" style={{ animation: "ggh-dot 2.4s ease-in-out infinite" }} />
              Selected Work — 2024/25
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl md:text-5xl dark:text-neutral-50">
              Colour lives on{" "}
              <span className="bg-gradient-to-r from-indigo-500 via-violet-500 to-rose-500 bg-clip-text text-transparent">
                contact
              </span>
              .
            </h2>
          </motion.div>

          <motion.p
            initial={{ opacity: 0, y: 18 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.6, delay: 0.08, ease: [0.22, 1, 0.36, 1] }}
            className="max-w-xs text-sm leading-relaxed text-neutral-500 dark:text-neutral-400"
          >
            A grayscale archive that wakes up under your cursor. Hover, or focus with the
            keyboard, to see each frame in full colour.
          </motion.p>
        </div>

        {/* grid */}
        <div className="grid auto-rows-[180px] grid-cols-2 gap-3 sm:auto-rows-[200px] sm:grid-cols-4 sm:gap-4">
          {SHOTS.map((shot, i) => {
            const isActive = active === i;
            return (
              <motion.a
                key={shot.src}
                href={shot.src}
                target="_blank"
                rel="noopener"
                aria-label={`${shot.title} — ${shot.category}, ${shot.location}. Open full image in a new tab.`}
                onMouseEnter={() => setActive(i)}
                onMouseLeave={() => setActive((p) => (p === i ? null : p))}
                onFocus={() => setActive(i)}
                onBlur={() => setActive((p) => (p === i ? null : p))}
                initial={{ opacity: 0, y: 28, scale: 0.985 }}
                animate={inView ? { opacity: 1, y: 0, scale: 1 } : {}}
                transition={{
                  duration: 0.6,
                  delay: 0.1 + i * 0.06,
                  ease: [0.22, 1, 0.36, 1],
                }}
                className={`ggh-card group relative overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-neutral-900/10 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-800 ${shot.span}`}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={shot.src}
                  alt={shot.alt}
                  loading="lazy"
                  draggable={false}
                  className={`ggh-img absolute inset-0 h-full w-full object-cover transition-[filter,transform] duration-[700ms] ease-[cubic-bezier(0.22,1,0.36,1)] ${
                    isActive
                      ? "scale-[1.06] grayscale-0"
                      : "scale-100 grayscale contrast-[1.02] group-hover:scale-[1.06] group-hover:grayscale-0"
                  }`}
                />

                {/* sheen swipe on activate */}
                {isActive && (
                  <span
                    aria-hidden
                    className="ggh-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent"
                  />
                )}

                {/* readability gradient */}
                <span
                  aria-hidden
                  className={`pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/80 via-neutral-950/10 to-transparent transition-opacity duration-500 ${
                    isActive ? "opacity-100" : "opacity-70 group-hover:opacity-100"
                  }`}
                />

                {/* corner category chip */}
                <span
                  className={`absolute left-3 top-3 rounded-full border border-white/25 bg-neutral-950/40 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.12em] text-white backdrop-blur-sm transition-all duration-500 ${
                    isActive
                      ? "translate-y-0 opacity-100"
                      : "-translate-y-1 opacity-0 group-hover:translate-y-0 group-hover:opacity-100"
                  }`}
                >
                  {shot.category}
                </span>

                {/* caption */}
                <div
                  className={`absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-3 transition-all duration-500 sm:p-4 ${
                    isActive
                      ? "translate-y-0 opacity-100"
                      : "translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100"
                  }`}
                >
                  <div className="min-w-0">
                    <h3 className="truncate text-sm font-semibold text-white sm:text-base">
                      {shot.title}
                    </h3>
                    <p className="truncate text-[11px] text-white/70">{shot.location}</p>
                  </div>
                  <span
                    aria-hidden
                    className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-white/90 text-neutral-900 transition-transform duration-500 group-hover:rotate-45"
                  >
                    <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
                      <path d="M7 17 17 7M9 7h8v8" />
                    </svg>
                  </span>
                </div>
              </motion.a>
            );
          })}
        </div>

        {/* footer note */}
        <motion.div
          initial={{ opacity: 0 }}
          animate={inView ? { opacity: 1 } : {}}
          transition={{ duration: 0.6, delay: 0.7 }}
          className="mt-10 flex items-center justify-center gap-2 text-xs text-neutral-400 dark:text-neutral-500"
        >
          <span className="h-px w-8 bg-neutral-300 dark:bg-neutral-700" />
          {SHOTS.length} frames · shot on 35mm and digital
          <span className="h-px w-8 bg-neutral-300 dark:bg-neutral-700" />
        </motion.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 →
Hover Caption Gallery

Hover Caption Gallery

Original

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

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.