Compare Grid Gallery
Original · freeA grid of before/after image comparison sliders.
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"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
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 quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

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

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

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

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

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

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

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

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

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

