Split Preview Gallery
Original · freeA two-column split: a list of titles on one side previews the selected image on the other with a crossfade.
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-split-preview.json"use client";
import { useState, useRef, useCallback } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
type Shot = {
id: string;
index: string;
title: string;
category: string;
location: string;
year: string;
src: string;
tone: string;
};
const SHOTS: Shot[] = [
{
id: "coastal-light",
index: "01",
title: "Coastal Light",
category: "Landscape",
location: "Cascais, PT",
year: "2024",
src: "/img/gallery/g03.webp",
tone: "#6ea8b8",
},
{
id: "studio-04",
index: "02",
title: "Studio 04",
category: "Portrait",
location: "Berlin, DE",
year: "2023",
src: "/img/gallery/g11.webp",
tone: "#c07a5a",
},
{
id: "quiet-terrain",
index: "03",
title: "Quiet Terrain",
category: "Nature",
location: "Dolomites, IT",
year: "2024",
src: "/img/gallery/g07.webp",
tone: "#7d8a63",
},
{
id: "concrete-hours",
index: "04",
title: "Concrete Hours",
category: "Architecture",
location: "Osaka, JP",
year: "2022",
src: "/img/gallery/g19.webp",
tone: "#8a8a92",
},
{
id: "amber-field",
index: "05",
title: "Amber Field",
category: "Landscape",
location: "Provence, FR",
year: "2023",
src: "/img/gallery/g24.webp",
tone: "#c9974a",
},
{
id: "still-objects",
index: "06",
title: "Still Objects",
category: "Editorial",
location: "Copenhagen, DK",
year: "2024",
src: "/img/gallery/g28.webp",
tone: "#9b7fa6",
},
{
id: "night-transit",
index: "07",
title: "Night Transit",
category: "Street",
location: "Seoul, KR",
year: "2023",
src: "/img/gallery/g15.webp",
tone: "#5f6f97",
},
{
id: "soft-tide",
index: "08",
title: "Soft Tide",
category: "Nature",
location: "Big Sur, US",
year: "2022",
src: "/img/gallery/g32.webp",
tone: "#5a9b8e",
},
];
export default function GallerySplitPreview() {
const [activeId, setActiveId] = useState<string>(SHOTS[0].id);
const [locked, setLocked] = useState<boolean>(false);
const sectionRef = useRef<HTMLDivElement>(null);
const inView = useInView(sectionRef, { once: true, margin: "-15% 0px" });
const active = SHOTS.find((s) => s.id === activeId) ?? SHOTS[0];
const preview = useCallback(
(id: string) => {
if (!locked) setActiveId(id);
},
[locked],
);
const select = useCallback((id: string) => {
setActiveId(id);
setLocked(true);
}, []);
const onKeyNav = useCallback(
(e: React.KeyboardEvent, i: number) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const dir = e.key === "ArrowDown" ? 1 : -1;
const next = (i + dir + SHOTS.length) % SHOTS.length;
setActiveId(SHOTS[next].id);
const el = document.getElementById(`gsp-item-${SHOTS[next].id}`);
el?.focus();
}
},
[],
);
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 gsp-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gsp-drift {
0% { transform: scale(1.06) translate3d(0,0,0); }
100% { transform: scale(1.11) translate3d(-1.2%, -1.4%, 0); }
}
.gsp-drift { animation: gsp-drift 14s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) {
.gsp-drift { animation: none !important; }
}
`}</style>
{/* ambient tint that shifts with the active image */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-40 transition-[background] duration-700 ease-out dark:opacity-30"
style={{
background: `radial-gradient(60% 55% at 78% 30%, ${active.tone}22, transparent 70%)`,
}}
/>
<div ref={sectionRef} className="relative mx-auto w-full max-w-7xl px-5 sm:px-8">
{/* header */}
<div className="mb-12 flex flex-col gap-4 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.28em] text-neutral-400 dark:text-neutral-500">
<span className="h-px w-6 bg-current" />
Selected Work
</span>
<h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">
Field Archive
</h2>
</div>
<p className="max-w-xs text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
A rotating index of frames from the road. Hover to preview, click to
hold a frame in place.
</p>
</div>
{/* split layout */}
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1.05fr_1fr] lg:gap-14">
{/* list side */}
<ul
className="order-2 flex flex-col lg:order-1"
onMouseLeave={() => setLocked(false)}
>
{SHOTS.map((shot, i) => {
const isActive = shot.id === active.id;
return (
<li
key={shot.id}
style={{
animation: inView
? `gsp-rise 0.6s cubic-bezier(0.22,1,0.36,1) ${i * 0.05}s both`
: undefined,
opacity: inView ? undefined : 0,
}}
>
<button
id={`gsp-item-${shot.id}`}
type="button"
onMouseEnter={() => preview(shot.id)}
onFocus={() => preview(shot.id)}
onClick={() => select(shot.id)}
onKeyDown={(e) => onKeyNav(e, i)}
aria-pressed={isActive}
aria-label={`Preview ${shot.title}, ${shot.category}, ${shot.location}`}
className="group relative flex w-full items-center gap-4 border-b border-neutral-200 py-5 text-left outline-none transition-colors focus-visible:bg-neutral-100 sm:gap-6 sm:py-6 dark:border-neutral-800 dark:focus-visible:bg-neutral-900"
>
{/* active rail */}
<span
aria-hidden
className="absolute left-0 top-0 h-full w-[2px] origin-top scale-y-0 bg-neutral-900 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-aria-pressed:scale-y-100 dark:bg-neutral-100"
/>
<span
className={`w-8 shrink-0 font-mono text-xs tabular-nums transition-colors duration-300 ${
isActive
? "text-neutral-900 dark:text-neutral-100"
: "text-neutral-400 dark:text-neutral-600"
}`}
>
{shot.index}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span
className={`truncate text-2xl font-medium tracking-tight transition-all duration-300 ease-out sm:text-3xl ${
isActive
? "translate-x-1 text-neutral-900 dark:text-neutral-50"
: "translate-x-0 text-neutral-500 dark:text-neutral-400"
}`}
>
{shot.title}
</span>
<span className="mt-1 flex items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-neutral-400 dark:text-neutral-500">
<span>{shot.category}</span>
<span className="h-px w-3 bg-current opacity-50" />
<span>{shot.location}</span>
</span>
</span>
{/* thumbnail chip */}
<span className="relative hidden h-14 w-14 shrink-0 overflow-hidden rounded-md bg-neutral-200 sm:block dark:bg-neutral-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt=""
loading="lazy"
draggable={false}
aria-hidden
className={`h-full w-full object-cover transition-all duration-500 ${
isActive
? "scale-105 opacity-100 grayscale-0"
: "opacity-70 grayscale group-hover:opacity-90"
}`}
/>
</span>
<svg
viewBox="0 0 24 24"
className={`h-5 w-5 shrink-0 transition-all duration-300 ${
isActive
? "translate-x-0 text-neutral-900 opacity-100 dark:text-neutral-100"
: "-translate-x-2 text-neutral-400 opacity-0 group-hover:translate-x-0 group-hover:opacity-70"
}`}
fill="none"
stroke="currentColor"
strokeWidth={1.6}
aria-hidden
>
<path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</li>
);
})}
<li className="mt-6 list-none text-xs text-neutral-400 dark:text-neutral-600">
{locked ? "Frame held — move away to release." : "Hovering preview active."}
</li>
</ul>
{/* preview side */}
<div className="order-1 lg:order-2 lg:sticky lg:top-8 lg:self-start">
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-2xl bg-neutral-200 shadow-[0_30px_80px_-40px_rgba(0,0,0,0.5)] ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/10">
<AnimatePresence mode="sync">
<motion.div
key={active.id}
initial={{ opacity: 0, scale: 1.02 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={`${active.title} — ${active.category} photographed in ${active.location}, ${active.year}`}
loading="lazy"
draggable={false}
className="gsp-drift h-full w-full object-cover"
/>
<div
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/5 to-transparent"
/>
</motion.div>
</AnimatePresence>
{/* meta overlay */}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-6 sm:p-7">
<AnimatePresence mode="wait">
<motion.div
key={active.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-white/70">
{active.category} · {active.year}
</p>
<h3 className="mt-1.5 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{active.title}
</h3>
<p className="mt-1 text-sm text-white/70">{active.location}</p>
</motion.div>
</AnimatePresence>
<span className="shrink-0 font-mono text-xs tabular-nums text-white/60">
{active.index} / {String(SHOTS.length).padStart(2, "0")}
</span>
</div>
{/* progress ticks */}
<div className="absolute right-6 top-6 flex flex-col items-end gap-1.5 sm:right-7 sm:top-7">
{SHOTS.map((s) => (
<span
key={s.id}
aria-hidden
className={`h-px rounded-full bg-white transition-all duration-500 ${
s.id === active.id ? "w-6 opacity-90" : "w-3 opacity-40"
}`}
/>
))}
</div>
</div>
</div>
</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.

