Web InnoventixFreeCode

Compare Grid Gallery

Original · free

A grid of before/after image comparison sliders.

byWeb InnoventixReact + Tailwind
comparegridgalleryimages
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-compare-grid.json
gallery-compare-grid.tsx
"use client";

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

type ComparePair = {
  before: string;
  after: string;
  beforeAlt: string;
  afterAlt: string;
  title: string;
  category: string;
  caption: string;
  ratio: "portrait" | "landscape" | "square";
};

const PAIRS: ComparePair[] = [
  {
    before: "/img/gallery/g01.webp",
    after: "/img/gallery/g02.webp",
    beforeAlt: "Studio portrait straight out of camera, flat and unretouched",
    afterAlt: "Same studio portrait after skin retouching and warm color grade",
    title: "Studio Portrait",
    category: "Beauty Retouch",
    caption: "Flat capture vs. dodge-and-burn grade",
    ratio: "portrait",
  },
  {
    before: "/img/gallery/g03.webp",
    after: "/img/gallery/g04.webp",
    beforeAlt: "Coastal shoreline at dawn, unprocessed with muted tones",
    afterAlt: "Coastal shoreline at dawn after teal-and-orange color grade",
    title: "Coastal Dawn",
    category: "Landscape",
    caption: "RAW file vs. teal & orange grade",
    ratio: "landscape",
  },
  {
    before: "/img/gallery/g05.webp",
    after: "/img/gallery/g06.webp",
    beforeAlt: "Concrete building facade shot flat in overcast light",
    afterAlt: "Same building facade after contrast and perspective correction",
    title: "Brutalist Facade",
    category: "Architecture",
    caption: "Overcast flat vs. corrected contrast",
    ratio: "square",
  },
  {
    before: "/img/gallery/g07.webp",
    after: "/img/gallery/g08.webp",
    beforeAlt: "Product bottle on a plain table with harsh shadows",
    afterAlt: "Product bottle composited onto a clean gradient with soft shadow",
    title: "Amber Bottle",
    category: "Product",
    caption: "On-set shot vs. composited hero",
    ratio: "portrait",
  },
  {
    before: "/img/gallery/g09.webp",
    after: "/img/gallery/g10.webp",
    beforeAlt: "Rainy street scene at night, underexposed and noisy",
    afterAlt: "Rainy street scene at night after noise reduction and neon lift",
    title: "Neon Rain",
    category: "Street",
    caption: "Underexposed vs. denoised & lifted",
    ratio: "landscape",
  },
  {
    before: "/img/gallery/g11.webp",
    after: "/img/gallery/g12.webp",
    beforeAlt: "Plated dish photographed under mixed kitchen lighting",
    afterAlt: "Same plated dish after white balance and highlight recovery",
    title: "Plated Course",
    category: "Food",
    caption: "Mixed light vs. balanced & crisp",
    ratio: "square",
  },
];

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

function CompareCard({ pair, index }: { pair: ComparePair; index: number }) {
  const [value, setValue] = useState<number>(50);
  const [focused, setFocused] = useState<boolean>(false);
  const [active, setActive] = useState<boolean>(false);
  const labelId = useId();
  const reduceMotion = useReducedMotion();

  const afterOpacity = Math.min(1, Math.max(0, (value - 8) / 22));
  const beforeOpacity = Math.min(1, Math.max(0, (92 - value) / 22));

  return (
    <motion.figure
      initial={reduceMotion ? false : { opacity: 0, y: 28 }}
      whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-60px" }}
      transition={reduceMotion ? { duration: 0 } : { duration: 0.6, delay: (index % 3) * 0.08, ease: [0.22, 1, 0.36, 1] }}
      className="group relative flex flex-col gap-3"
    >
      <div
        className={[
          "relative w-full overflow-hidden rounded-2xl select-none",
          "bg-slate-200 dark:bg-zinc-800",
          "ring-1 ring-slate-900/10 dark:ring-white/10",
          "shadow-sm transition-shadow duration-300 group-hover:shadow-xl group-hover:shadow-slate-900/10 dark:group-hover:shadow-black/40",
          RATIO_CLASS[pair.ratio],
          focused ? "ring-2 ring-indigo-500 dark:ring-indigo-400" : "",
        ].join(" ")}
      >
        {/* AFTER image sits underneath, fully visible */}
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={pair.after}
          alt={pair.afterAlt}
          loading="lazy"
          draggable={false}
          className="absolute inset-0 h-full w-full object-cover"
        />

        {/* BEFORE image clipped from the right edge, revealed by the slider */}
        <div
          className="absolute inset-0"
          style={{ clipPath: `inset(0 ${100 - value}% 0 0)` }}
        >
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={pair.before}
            alt={pair.beforeAlt}
            loading="lazy"
            draggable={false}
            className="absolute inset-0 h-full w-full object-cover"
          />
          <span
            aria-hidden="true"
            className="pointer-events-none absolute left-3 top-3 rounded-full bg-slate-900/70 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-white backdrop-blur-sm transition-opacity duration-200"
            style={{ opacity: beforeOpacity }}
          >
            Before
          </span>
        </div>

        <span
          aria-hidden="true"
          className="pointer-events-none absolute right-3 top-3 rounded-full bg-indigo-600/85 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-white backdrop-blur-sm transition-opacity duration-200"
          style={{ opacity: afterOpacity }}
        >
          After
        </span>

        {/* Divider line + handle */}
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 z-10 flex w-0 items-center justify-center"
          style={{ left: `${value}%` }}
        >
          <div className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-white/90 shadow-[0_0_0_1px_rgba(15,23,42,0.25)]" />
          <div
            className={[
              "gcg-handle relative flex h-9 w-9 -translate-x-1/2 items-center justify-center rounded-full",
              "bg-white text-slate-700 shadow-lg ring-1 ring-slate-900/10",
              "transition-transform duration-200",
              active ? "scale-110" : "group-hover:scale-105",
              focused ? "ring-2 ring-indigo-500" : "",
            ].join(" ")}
          >
            <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
              <path d="M9 7l-4 5 4 5" />
              <path d="M15 7l4 5-4 5" />
            </svg>
          </div>
        </div>

        {/* Native range for interaction + full keyboard support */}
        <label htmlFor={labelId} className="sr-only">
          {`Compare ${pair.title}: drag to reveal the edited version. Currently showing ${Math.round(value)} percent of the original.`}
        </label>
        <input
          id={labelId}
          type="range"
          min={0}
          max={100}
          step={0.5}
          value={value}
          onChange={(e) => setValue(Number(e.target.value))}
          onFocus={() => setFocused(true)}
          onBlur={() => {
            setFocused(false);
            setActive(false);
          }}
          onPointerDown={() => setActive(true)}
          onPointerUp={() => setActive(false)}
          aria-valuetext={`${Math.round(value)}% original, ${Math.round(100 - value)}% edited`}
          className="gcg-range absolute inset-0 z-20 h-full w-full cursor-ew-resize appearance-none bg-transparent focus:outline-none"
        />
      </div>

      <figcaption className="flex items-start justify-between gap-3 px-0.5">
        <div>
          <h3 className="text-sm font-semibold text-slate-900 dark:text-zinc-100">
            {pair.title}
          </h3>
          <p className="mt-0.5 text-xs text-slate-500 dark:text-zinc-400">
            {pair.caption}
          </p>
        </div>
        <span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 ring-1 ring-inset ring-slate-900/5 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-white/10">
          {pair.category}
        </span>
      </figcaption>
    </motion.figure>
  );
}

export default function GalleryCompareGrid() {
  const sectionRef = useRef<HTMLElement>(null);

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-white px-5 py-20 sm:px-8 sm:py-24 lg:px-12 dark:bg-zinc-950"
    >
      <style>{`
        @keyframes gcg-hint {
          0%, 100% { transform: translateX(-50%) scale(1); }
          50% { transform: translateX(-50%) scale(1.08); }
        }
        .gcg-handle {
          animation: gcg-hint 2.6s ease-in-out infinite;
        }
        .group:hover .gcg-handle,
        .group:focus-within .gcg-handle {
          animation: none;
        }
        .gcg-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          width: 44px;
          height: 100%;
          background: transparent;
          cursor: ew-resize;
        }
        .gcg-range::-moz-range-thumb {
          width: 44px;
          height: 999px;
          border: none;
          background: transparent;
          cursor: ew-resize;
        }
        .gcg-range::-moz-range-track {
          background: transparent;
        }
        @media (prefers-reduced-motion: reduce) {
          .gcg-handle { animation: none !important; }
        }
      `}</style>

      {/* soft background accents */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10"
      >
        <div className="absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10" />
      </div>

      <div className="mx-auto max-w-6xl">
        <header className="mb-12 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-900/5 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-white/10">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Before / After
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
            The edit, side by side
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            Drag any handle to wipe between the original capture and the finished
            grade. Every frame is real work from the studio, no presets.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {PAIRS.map((pair, i) => (
            <CompareCard key={pair.before} pair={pair} index={i} />
          ))}
        </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.