Web InnoventixFreeCode

Split Preview Gallery

Original · free

A two-column split: a list of titles on one side previews the selected image on the other with a crossfade.

byWeb InnoventixReact + Tailwind
splitpreviewgalleryimages
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-split-preview.json
gallery-split-preview.tsx
"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

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.