Web InnoventixFreeCode

Clip Reveal Gallery

Original · free

A gallery where each image is revealed with a clip-path wipe on hover, with a lightbox.

byWeb InnoventixReact + Tailwind
cliprevealgalleryimages
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-clip-reveal.json
gallery-clip-reveal.tsx
"use client";

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

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

interface Shot {
  id: number;
  src: string;
  title: string;
  category: string;
  location: string;
  orientation: Orientation;
}

const SHOTS: Shot[] = [
  {
    id: 1,
    src: "/img/gallery/g03.webp",
    title: "Cathedral of Light",
    category: "Architecture",
    location: "Valencia, Spain",
    orientation: "portrait",
  },
  {
    id: 2,
    src: "/img/gallery/g11.webp",
    title: "Low Tide, First Light",
    category: "Seascape",
    location: "Jökulsárlón, Iceland",
    orientation: "landscape",
  },
  {
    id: 3,
    src: "/img/gallery/g07.webp",
    title: "The Weaver's Hands",
    category: "Portrait",
    location: "Marrakesh, Morocco",
    orientation: "square",
  },
  {
    id: 4,
    src: "/img/gallery/g19.webp",
    title: "Concrete & Fog",
    category: "Urban",
    location: "Chongqing, China",
    orientation: "portrait",
  },
  {
    id: 5,
    src: "/img/gallery/g22.webp",
    title: "Salt Flats at Dusk",
    category: "Landscape",
    location: "Uyuni, Bolivia",
    orientation: "landscape",
  },
  {
    id: 6,
    src: "/img/gallery/g14.webp",
    title: "Midnight Market",
    category: "Street",
    location: "Bangkok, Thailand",
    orientation: "square",
  },
  {
    id: 7,
    src: "/img/gallery/g27.webp",
    title: "Terraces of Rice",
    category: "Aerial",
    location: "Bali, Indonesia",
    orientation: "portrait",
  },
  {
    id: 8,
    src: "/img/gallery/g31.webp",
    title: "The Blue Hour Bridge",
    category: "Cityscape",
    location: "Porto, Portugal",
    orientation: "landscape",
  },
  {
    id: 9,
    src: "/img/gallery/g09.webp",
    title: "Dunes in Motion",
    category: "Desert",
    location: "Sossusvlei, Namibia",
    orientation: "square",
  },
  {
    id: 10,
    src: "/img/gallery/g34.webp",
    title: "Neon After Rain",
    category: "Nightlife",
    location: "Osaka, Japan",
    orientation: "portrait",
  },
];

const ASPECT: Record<Orientation, string> = {
  portrait: "4 / 5.2",
  landscape: "1040 / 720",
  square: "1 / 1",
};

function GalleryCard({ shot, index, onOpen }: { shot: Shot; index: number; onOpen: (i: number) => void }) {
  return (
    <motion.figure
      initial={{ opacity: 0, y: 26 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-60px" }}
      transition={{ duration: 0.6, delay: (index % 3) * 0.08, ease: [0.22, 1, 0.36, 1] }}
      className="gcr-card group relative mb-5 block w-full break-inside-avoid overflow-hidden rounded-2xl bg-slate-200 shadow-sm ring-1 ring-slate-900/5 dark:bg-zinc-800 dark:ring-white/10"
    >
      <button
        type="button"
        onClick={() => onOpen(index)}
        aria-label={`View ${shot.title}, ${shot.category} — ${shot.location}`}
        className="relative block w-full cursor-zoom-in outline-none"
        style={{ aspectRatio: ASPECT[shot.orientation] }}
      >
        {/* Base layer: dimmed, desaturated resting state */}
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={shot.src}
          alt={`${shot.title} — ${shot.category} photographed in ${shot.location}`}
          loading="lazy"
          draggable={false}
          className="gcr-base absolute inset-0 h-full w-full object-cover"
        />

        {/* Reveal layer: full colour, wiped in via clip-path on hover/focus */}
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={shot.src}
          alt=""
          aria-hidden="true"
          loading="lazy"
          draggable={false}
          className="gcr-reveal absolute inset-0 h-full w-full object-cover"
        />

        {/* Wipe seam accent */}
        <span aria-hidden="true" className="gcr-seam pointer-events-none absolute inset-y-0 w-[2px] bg-white/70 mix-blend-overlay" />

        {/* Gradient scrim for caption legibility */}
        <span
          aria-hidden="true"
          className="gcr-scrim pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent"
        />

        {/* Caption: fades and lifts in on reveal */}
        <figcaption className="gcr-cap pointer-events-none absolute inset-x-0 bottom-0 p-5 text-left">
          <span className="gcr-cat inline-flex items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white backdrop-blur-sm ring-1 ring-white/25">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
            {shot.category}
          </span>
          <h3 className="gcr-title mt-2.5 text-lg font-semibold leading-tight text-white drop-shadow-sm">
            {shot.title}
          </h3>
          <p className="gcr-loc mt-0.5 text-sm font-medium text-white/70">{shot.location}</p>
        </figcaption>
      </button>
    </motion.figure>
  );
}

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

  const close = useCallback(() => setActive(null), []);
  const next = useCallback(() => setActive((i) => (i === null ? i : (i + 1) % SHOTS.length)), []);
  const prev = useCallback(() => setActive((i) => (i === null ? i : (i - 1 + SHOTS.length) % SHOTS.length)), []);

  useEffect(() => {
    if (active === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") next();
      else if (e.key === "ArrowLeft") prev();
    };
    window.addEventListener("keydown", onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
    };
  }, [active, close, next, prev]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 sm:px-10 lg:px-16 dark:bg-zinc-950">
      <style>{`
        .gcr-card .gcr-base {
          transition: filter .6s cubic-bezier(.22,1,.36,1), transform .8s cubic-bezier(.22,1,.36,1);
          filter: grayscale(1) brightness(.82) contrast(.95);
          transform: scale(1.02);
        }
        .gcr-card .gcr-reveal {
          clip-path: inset(0 100% 0 0);
          transition: clip-path .7s cubic-bezier(.76,0,.24,1), transform .9s cubic-bezier(.22,1,.36,1);
          transform: scale(1.06);
          will-change: clip-path, transform;
        }
        .gcr-card .gcr-seam {
          left: 0;
          opacity: 0;
          transition: left .7s cubic-bezier(.76,0,.24,1), opacity .7s ease;
        }
        .gcr-card .gcr-scrim {
          opacity: 0;
          transition: opacity .6s ease;
        }
        .gcr-card .gcr-cap > * {
          opacity: 0;
          transform: translateY(14px);
          transition: opacity .55s ease, transform .6s cubic-bezier(.22,1,.36,1);
        }
        .gcr-card .gcr-cat { transition-delay: .06s; }
        .gcr-card .gcr-title { transition-delay: .13s; }
        .gcr-card .gcr-loc { transition-delay: .19s; }

        .gcr-card:hover .gcr-base,
        .gcr-card:focus-within .gcr-base {
          filter: grayscale(0) brightness(1) contrast(1);
          transform: scale(1.04);
        }
        .gcr-card:hover .gcr-reveal,
        .gcr-card:focus-within .gcr-reveal {
          clip-path: inset(0 0 0 0);
          transform: scale(1);
        }
        .gcr-card:hover .gcr-seam,
        .gcr-card:focus-within .gcr-seam {
          left: 100%;
          opacity: 1;
        }
        .gcr-card:hover .gcr-scrim,
        .gcr-card:focus-within .gcr-scrim { opacity: 1; }
        .gcr-card:hover .gcr-cap > *,
        .gcr-card:focus-within .gcr-cap > * {
          opacity: 1;
          transform: translateY(0);
        }
        .gcr-card button:focus-visible {
          outline: 2px solid rgb(16 185 129);
          outline-offset: 3px;
        }

        @keyframes gcr-lb-in {
          from { opacity: 0; transform: scale(.94); clip-path: inset(6% 6% 6% 6%); }
          to   { opacity: 1; transform: scale(1); clip-path: inset(0 0 0 0); }
        }
        .gcr-lb-figure { animation: gcr-lb-in .5s cubic-bezier(.22,1,.36,1) both; }

        @media (prefers-reduced-motion: reduce) {
          .gcr-card .gcr-base,
          .gcr-card .gcr-reveal,
          .gcr-card .gcr-seam,
          .gcr-card .gcr-scrim,
          .gcr-card .gcr-cap > * {
            transition: none !important;
          }
          .gcr-card .gcr-reveal { clip-path: inset(0 0 0 0); transform: none; }
          .gcr-card .gcr-base { filter: none; transform: none; }
          .gcr-card .gcr-scrim { opacity: 1; }
          .gcr-card .gcr-cap > * { opacity: 1; transform: none; }
          .gcr-lb-figure { animation: none; }
        }
      `}</style>

      {/* Ambient background wash */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-40 left-1/2 h-[38rem] w-[38rem] -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-violet-200/30 to-transparent blur-3xl dark:from-indigo-600/20 dark:via-violet-700/10"
      />

      <div className="relative mx-auto max-w-7xl">
        <div ref={headRef} className="mx-auto max-w-2xl text-center">
          <motion.span
            initial={{ opacity: 0, y: 12 }}
            animate={headInView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.5 }}
            className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-600 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-slate-300"
          >
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Field Notes · 2026
          </motion.span>

          <motion.h2
            initial={{ opacity: 0, y: 18 }}
            animate={headInView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.6, delay: 0.08 }}
            className="mt-6 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white"
          >
            Frames that reveal themselves
          </motion.h2>

          <motion.p
            initial={{ opacity: 0, y: 18 }}
            animate={headInView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.6, delay: 0.16 }}
            className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-400"
          >
            A travelling archive of ten stills. Hover or focus a frame and it wipes
            into full colour — the story arriving one edge at a time.
          </motion.p>
        </div>

        {/* Masonry columns */}
        <div className="mt-16 gap-5 [column-fill:_balance] sm:columns-2 lg:columns-3">
          {SHOTS.map((shot, i) => (
            <GalleryCard key={shot.id} shot={shot} index={i} onOpen={setActive} />
          ))}
        </div>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {active !== null && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.28 }}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/90 p-4 backdrop-blur-md sm:p-8"
            role="dialog"
            aria-modal="true"
            aria-label={`${SHOTS[active].title}, image viewer`}
            tabIndex={-1}
            onClick={close}
          >
            <button
              type="button"
              onClick={close}
              aria-label="Close viewer"
              className="absolute right-4 top-4 z-10 inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white ring-1 ring-white/25 backdrop-blur transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-emerald-400"
            >
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <path d="M6 6l12 12M18 6L6 18" />
              </svg>
            </button>

            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); prev(); }}
              aria-label="Previous image"
              className="absolute left-3 top-1/2 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white ring-1 ring-white/25 backdrop-blur transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-emerald-400 sm:left-6"
            >
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M15 6l-6 6 6 6" />
              </svg>
            </button>

            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); next(); }}
              aria-label="Next image"
              className="absolute right-3 top-1/2 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white ring-1 ring-white/25 backdrop-blur transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-emerald-400 sm:right-6"
            >
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M9 6l6 6-6 6" />
              </svg>
            </button>

            <figure
              key={SHOTS[active].id}
              className="gcr-lb-figure relative max-h-[86vh] w-auto max-w-5xl overflow-hidden rounded-2xl ring-1 ring-white/15"
              onClick={(e) => e.stopPropagation()}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={SHOTS[active].src}
                alt={`${SHOTS[active].title} — ${SHOTS[active].category} photographed in ${SHOTS[active].location}`}
                loading="lazy"
                draggable={false}
                className="max-h-[86vh] w-auto max-w-full object-contain"
              />
              <figcaption className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-between gap-2 bg-gradient-to-t from-slate-950/90 to-transparent p-6">
                <div>
                  <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-300">
                    {SHOTS[active].category}
                  </span>
                  <h3 className="mt-1 text-xl font-semibold text-white">{SHOTS[active].title}</h3>
                  <p className="text-sm text-white/70">{SHOTS[active].location}</p>
                </div>
                <span className="text-sm font-medium tabular-nums text-white/60">
                  {active + 1} / {SHOTS.length}
                </span>
              </figcaption>
            </figure>
          </motion.div>
        )}
      </AnimatePresence>
    </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.