Web InnoventixFreeCode

Deck Cycle Gallery

Original · free

A 3D stacked deck of photos; clicking sends the top card to the back.

byWeb InnoventixReact + Tailwind
deckcyclegalleryimages
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-deck-cycle.json
gallery-deck-cycle.tsx
"use client";

import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type Slide = {
  id: number;
  src: string;
  alt: string;
  title: string;
  category: string;
  caption: string;
  location: string;
  tone: string;
};

const SLIDES: Slide[] = [
  {
    id: 1,
    src: "/img/gallery/g04.webp",
    alt: "Low winter sun breaking through a stand of frost-covered pines",
    title: "First Light, Kvaløya",
    category: "Landscape",
    caption: "Six minutes of usable gold before the ridge swallowed the sun again.",
    location: "Tromsø, Norway",
    tone: "amber",
  },
  {
    id: 2,
    src: "/img/gallery/g11.webp",
    alt: "Portrait of a ceramicist holding a freshly thrown bowl, hands dusted with clay",
    title: "The Potter's Hands",
    category: "Portrait",
    caption: "Shot on the studio's north window — no reflector, no flash, just patience.",
    location: "Stoke-on-Trent, UK",
    tone: "rose",
  },
  {
    id: 3,
    src: "/img/gallery/g19.webp",
    alt: "Aerial view of turquoise river braids cutting through dark volcanic sand",
    title: "Braided Delta",
    category: "Aerial",
    caption: "Flown at 400 ft; the meltwater rewrites these channels every single week.",
    location: "Landmannalaugar, IS",
    tone: "emerald",
  },
  {
    id: 4,
    src: "/img/gallery/g27.webp",
    alt: "Rain-slicked neon alley reflected in a puddle at night",
    title: "After the Downpour",
    category: "Street",
    caption: "Waited out the storm under an awning; the reflections were the whole point.",
    location: "Shinjuku, Tokyo",
    tone: "indigo",
  },
  {
    id: 5,
    src: "/img/gallery/g08.webp",
    alt: "Close macro of dew beading on a spider's orb web at dawn",
    title: "Tension & Dew",
    category: "Macro",
    caption: "A 90mm macro at f/8, focus-stacked from nineteen frames handheld.",
    location: "Peak District, UK",
    tone: "violet",
  },
  {
    id: 6,
    src: "/img/gallery/g33.webp",
    alt: "Silhouetted surfer walking a black-sand beach under heavy clouds",
    title: "Between Sets",
    category: "Documentary",
    caption: "The swell was flat for an hour — sometimes the wait is the story.",
    location: "Reynisfjara, IS",
    tone: "amber",
  },
  {
    id: 7,
    src: "/img/gallery/g15.webp",
    alt: "Overhead flat-lay of a market stall stacked with heirloom tomatoes",
    title: "Heirloom Table",
    category: "Still Life",
    caption: "Natural light, styled in eight minutes before the vendor sold the lot.",
    location: "Ortygia, Sicily",
    tone: "rose",
  },
  {
    id: 8,
    src: "/img/gallery/g22.webp",
    alt: "Long-exposure of star trails wheeling above a lone desert arch",
    title: "Two Hours of Sky",
    category: "Astro",
    caption: "Ninety 90-second frames stacked; the arch was lit by a passing car.",
    location: "Moab, Utah",
    tone: "indigo",
  },
];

const TONE: Record<
  string,
  { ring: string; chip: string; glow: string; dot: string }
> = {
  amber: {
    ring: "ring-amber-300/60 dark:ring-amber-400/30",
    chip: "bg-amber-100 text-amber-800 dark:bg-amber-400/15 dark:text-amber-200",
    glow: "shadow-amber-500/20",
    dot: "bg-amber-500",
  },
  rose: {
    ring: "ring-rose-300/60 dark:ring-rose-400/30",
    chip: "bg-rose-100 text-rose-800 dark:bg-rose-400/15 dark:text-rose-200",
    glow: "shadow-rose-500/20",
    dot: "bg-rose-500",
  },
  emerald: {
    ring: "ring-emerald-300/60 dark:ring-emerald-400/30",
    chip: "bg-emerald-100 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-200",
    glow: "shadow-emerald-500/20",
    dot: "bg-emerald-500",
  },
  indigo: {
    ring: "ring-indigo-300/60 dark:ring-indigo-400/30",
    chip: "bg-indigo-100 text-indigo-800 dark:bg-indigo-400/15 dark:text-indigo-200",
    glow: "shadow-indigo-500/20",
    dot: "bg-indigo-500",
  },
  violet: {
    ring: "ring-violet-300/60 dark:ring-violet-400/30",
    chip: "bg-violet-100 text-violet-800 dark:bg-violet-400/15 dark:text-violet-200",
    glow: "shadow-violet-500/20",
    dot: "bg-violet-500",
  },
};

const VISIBLE = 5;

export default function GalleryDeckCycle() {
  const reduce = useReducedMotion();
  const [order, setOrder] = useState<number[]>(() =>
    SLIDES.map((_, i) => i)
  );
  const [nudge, setNudge] = useState<0 | 1 | -1>(0);

  const cycleForward = useCallback(() => {
    setOrder((prev) => {
      const next = prev.slice();
      const top = next.shift();
      if (top !== undefined) next.push(top);
      return next;
    });
  }, []);

  const cycleBack = useCallback(() => {
    setOrder((prev) => {
      const next = prev.slice();
      const last = next.pop();
      if (last !== undefined) next.unshift(last);
      return next;
    });
  }, []);

  const positions = useMemo(() => {
    const map = new Map<number, number>();
    order.forEach((slideIndex, pos) => map.set(slideIndex, pos));
    return map;
  }, [order]);

  const topSlide = SLIDES[order[0]];
  const total = SLIDES.length;

  const onKeyDeck = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
        e.preventDefault();
        setNudge(-1);
        cycleForward();
      } else if (e.key === "ArrowLeft") {
        e.preventDefault();
        setNudge(1);
        cycleBack();
      }
    },
    [cycleForward, cycleBack]
  );

  return (
    <section
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-6 py-20 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
      aria-label="Deck cycle photo gallery"
    >
      <style>{`
        @keyframes gdc-float {
          0%, 100% { transform: translateY(0px); }
          50% { transform: translateY(-8px); }
        }
        @keyframes gdc-sheen {
          0% { transform: translateX(-120%) skewX(-12deg); opacity: 0; }
          18% { opacity: 0.55; }
          40% { transform: translateX(220%) skewX(-12deg); opacity: 0; }
          100% { transform: translateX(220%) skewX(-12deg); opacity: 0; }
        }
        @keyframes gdc-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .gdc-float { animation: gdc-float 7s ease-in-out infinite; }
        .gdc-sheen { animation: gdc-sheen 6.5s ease-in-out infinite; }
        .gdc-rise { animation: gdc-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .gdc-float, .gdc-sheen, .gdc-rise { animation: none !important; }
        }
      `}</style>

      {/* ambient blobs */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-rose-300/25 blur-3xl dark:bg-violet-700/15"
      />

      <div className="relative mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1fr_minmax(360px,520px)] lg:gap-8">
        {/* left: copy + live meta */}
        <div className="gdc-rise order-2 lg:order-1">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Field Portfolio · {new Date().getFullYear()}
          </span>

          <h2 className="mt-5 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
            The Deck
          </h2>
          <p className="mt-4 max-w-md text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            Eight frames from the road, stacked like contact prints on a
            lightbox. Flip through the pile — tap the top photo, drag it away, or
            use the arrow keys.
          </p>

          <div
            key={topSlide.id}
            className="gdc-rise mt-8 max-w-md rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/60"
          >
            <div className="flex items-center gap-2">
              <span
                className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${TONE[topSlide.tone].chip}`}
              >
                {topSlide.category}
              </span>
              <span className="text-xs text-slate-400 dark:text-zinc-500">
                {topSlide.location}
              </span>
            </div>
            <p className="mt-3 text-lg font-medium text-slate-900 dark:text-white">
              {topSlide.title}
            </p>
            <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
              {topSlide.caption}
            </p>
          </div>

          <div className="mt-8 flex items-center gap-4">
            <button
              type="button"
              onClick={() => {
                setNudge(1);
                cycleBack();
              }}
              aria-label="Send previous photo back to the top of the deck"
              className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-700 dark:focus-visible:ring-offset-black"
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M15 18l-6-6 6-6" />
              </svg>
            </button>

            <button
              type="button"
              onClick={() => {
                setNudge(-1);
                cycleForward();
              }}
              className="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-medium text-white transition hover:-translate-y-0.5 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:focus-visible:ring-offset-black"
            >
              Next frame
              <svg
                width="16"
                height="16"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
                className="transition-transform group-hover:translate-x-0.5"
              >
                <path d="M5 12h14M13 6l6 6-6 6" />
              </svg>
            </button>

            <div
              className="ml-auto flex items-center gap-1.5"
              role="tablist"
              aria-label="Deck position"
            >
              {SLIDES.map((s, i) => {
                const isTop = positions.get(i) === 0;
                return (
                  <span
                    key={s.id}
                    role="tab"
                    aria-selected={isTop}
                    aria-label={`${s.title}${isTop ? " (on top)" : ""}`}
                    className={`h-1.5 rounded-full transition-all duration-500 ${
                      isTop
                        ? `w-6 ${TONE[s.tone].dot}`
                        : "w-1.5 bg-slate-300 dark:bg-zinc-700"
                    }`}
                  />
                );
              })}
            </div>
          </div>
        </div>

        {/* right: the deck */}
        <div className="order-1 flex justify-center lg:order-2">
          <div
            role="button"
            tabIndex={0}
            aria-label={`Photo deck, ${total} frames. Currently showing ${topSlide.title}. Press Enter or the right arrow to send the top photo to the back.`}
            onKeyDown={onKeyDeck}
            onClick={() => {
              setNudge(-1);
              cycleForward();
            }}
            className="group relative h-[440px] w-[320px] cursor-pointer select-none rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-slate-50 sm:h-[500px] sm:w-[360px] dark:focus-visible:ring-offset-zinc-950"
            style={{ perspective: "1400px" }}
          >
            <div
              className="gdc-float absolute inset-0"
              style={{ transformStyle: "preserve-3d" }}
            >
              {SLIDES.map((slide, i) => {
                const pos = positions.get(i) ?? 0;
                const hidden = pos >= VISIBLE;
                const tone = TONE[slide.tone];

                const depth = Math.min(pos, VISIBLE);
                const y = depth * 20;
                const scale = 1 - depth * 0.05;
                const z = -depth * 60;
                const rot = pos === 0 ? 0 : (i % 2 === 0 ? 1 : -1) * (1.5 + depth);
                const opacity = hidden ? 0 : 1 - depth * 0.12;

                return (
                  <motion.figure
                    key={slide.id}
                    initial={false}
                    animate={
                      reduce
                        ? { opacity: pos === 0 ? 1 : 0.4 }
                        : {
                            y,
                            scale,
                            z,
                            rotateZ: rot,
                            rotateX: pos === 0 ? 0 : 3,
                            opacity,
                            x: pos === 0 ? nudge * 2 : 0,
                          }
                    }
                    transition={
                      reduce
                        ? { duration: 0 }
                        : {
                            type: "spring",
                            stiffness: 260,
                            damping: 30,
                            mass: 0.9,
                          }
                    }
                    style={{
                      transformStyle: "preserve-3d",
                      zIndex: total - pos,
                      pointerEvents: pos === 0 ? "auto" : "none",
                    }}
                    className={`absolute inset-0 m-0 overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ${tone.ring} ${tone.glow} dark:bg-zinc-900`}
                  >
                    <div className="relative h-full w-full">
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img
                        src={slide.src}
                        alt={slide.alt}
                        loading="lazy"
                        draggable={false}
                        className="h-full w-full object-cover"
                      />

                      {/* legibility gradient */}
                      <div
                        aria-hidden="true"
                        className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent"
                      />

                      {/* animated sheen only on the top card */}
                      {pos === 0 && (
                        <div
                          aria-hidden="true"
                          className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl"
                        >
                          <div className="gdc-sheen absolute -inset-y-4 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent" />
                        </div>
                      )}

                      <figcaption className="absolute inset-x-0 bottom-0 p-5 text-left">
                        <span
                          className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${tone.chip}`}
                        >
                          {slide.category}
                        </span>
                        <p className="mt-2 text-lg font-semibold leading-tight text-white drop-shadow-sm">
                          {slide.title}
                        </p>
                        <p className="mt-0.5 text-xs font-medium text-white/70">
                          {slide.location}
                        </p>
                      </figcaption>

                      {/* frame counter badge on top card */}
                      {pos === 0 && (
                        <div className="absolute right-4 top-4 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-slate-800 backdrop-blur dark:bg-zinc-900/80 dark:text-zinc-100">
                          {String(i + 1).padStart(2, "0")}
                          <span className="text-slate-400 dark:text-zinc-500">
                            {" "}
                            / {String(total).padStart(2, "0")}
                          </span>
                        </div>
                      )}
                    </div>
                  </motion.figure>
                );
              })}
            </div>

            {/* tap hint */}
            <div className="pointer-events-none absolute -bottom-9 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium text-slate-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:text-zinc-500">
              tap to send to back
            </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.