Web InnoventixFreeCode

Polaroid Stack Gallery

Original · free

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

byWeb InnoventixReact + Tailwind
polaroidstackgalleryimages
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-polaroid-stack.json
gallery-polaroid-stack.tsx
"use client";

import { useRef, useState } from "react";
import {
  motion,
  useInView,
  useReducedMotion,
  type Variants,
} from "motion/react";

type Polaroid = {
  src: string;
  title: string;
  caption: string;
  category: string;
  rotate: number;
  // position as a percentage of the board, tuned for a hand-scattered look
  left: string;
  top: string;
  z: number;
};

const PHOTOS: Polaroid[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal light",
    caption: "Low tide, 6:42am",
    category: "Landscape",
    rotate: -7,
    left: "4%",
    top: "8%",
    z: 2,
  },
  {
    src: "/img/gallery/g08.webp",
    title: "Studio 04",
    caption: "North-facing window",
    category: "Portrait",
    rotate: 5,
    left: "23%",
    top: "2%",
    z: 4,
  },
  {
    src: "/img/gallery/g15.webp",
    title: "Field notes",
    caption: "Wild grass in July",
    category: "Nature",
    rotate: -4,
    left: "43%",
    top: "10%",
    z: 3,
  },
  {
    src: "/img/gallery/g21.webp",
    title: "Quiet harbour",
    caption: "Boats before the storm",
    category: "Travel",
    rotate: 8,
    left: "62%",
    top: "3%",
    z: 5,
  },
  {
    src: "/img/gallery/g27.webp",
    title: "Warm concrete",
    caption: "Afternoon on the stairs",
    category: "Architecture",
    rotate: -6,
    left: "80%",
    top: "12%",
    z: 2,
  },
  {
    src: "/img/gallery/g11.webp",
    title: "First frost",
    caption: "The garden turns silver",
    category: "Nature",
    rotate: 6,
    left: "10%",
    top: "44%",
    z: 6,
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Golden hour",
    caption: "Rooftops going amber",
    category: "Cityscape",
    rotate: -5,
    left: "31%",
    top: "50%",
    z: 3,
  },
  {
    src: "/img/gallery/g30.webp",
    title: "Still life",
    caption: "Fruit and morning shadow",
    category: "Studio",
    rotate: 4,
    left: "52%",
    top: "46%",
    z: 5,
  },
  {
    src: "/img/gallery/g34.webp",
    title: "Open road",
    caption: "Somewhere past the ridge",
    category: "Travel",
    rotate: -8,
    left: "72%",
    top: "52%",
    z: 4,
  },
];

export default function GalleryPolaroidStack() {
  const sectionRef = useRef<HTMLElement | null>(null);
  const boardRef = useRef<HTMLDivElement | null>(null);
  const inView = useInView(boardRef, { once: true, margin: "-80px" });
  const reduceMotion = useReducedMotion();
  const [active, setActive] = useState<number | null>(null);

  const cardVariants: Variants = {
    hidden: (rotate: number) => ({
      opacity: 0,
      y: reduceMotion ? 0 : 40,
      rotate: reduceMotion ? rotate : rotate * 2.2,
      scale: reduceMotion ? 1 : 0.9,
    }),
    show: (rotate: number) => ({
      opacity: 1,
      y: 0,
      rotate,
      scale: 1,
      transition: {
        type: "spring",
        stiffness: 120,
        damping: 16,
        mass: 0.7,
      },
    }),
  };

  return (
    <section
      ref={sectionRef}
      aria-labelledby="polaroid-stack-heading"
      className="relative w-full overflow-hidden bg-neutral-50 px-5 py-20 sm:px-8 sm:py-28 dark:bg-neutral-950"
    >
      <style>{`
        @keyframes gps-tape-sheen {
          0% { transform: translateX(-120%) skewX(-18deg); opacity: 0; }
          40% { opacity: 0.7; }
          100% { transform: translateX(320%) skewX(-18deg); opacity: 0; }
        }
        @media (min-width: 1024px) {
          .gps-card {
            position: absolute;
            left: var(--gps-l);
            top: var(--gps-t);
            margin: 0;
          }
        }
        @media (prefers-reduced-motion: reduce) {
          .gps-tape-sheen { animation: none !important; }
        }
      `}</style>

      {/* soft ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
      >
        <div className="absolute -top-32 left-1/4 h-96 w-96 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute -bottom-24 right-1/5 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10" />
        <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_55%,rgba(0,0,0,0.04))] dark:bg-[radial-gradient(circle_at_center,transparent_55%,rgba(0,0,0,0.35))]" />
      </div>

      <div className="relative mx-auto max-w-6xl">
        <div className="mb-14 max-w-2xl sm:mb-20">
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-neutral-600 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/70 dark:text-neutral-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            The film drawer
          </span>
          <h2
            id="polaroid-stack-heading"
            className="mt-5 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl md:text-5xl dark:text-neutral-50"
          >
            Scattered on the table
          </h2>
          <p className="mt-4 text-base leading-relaxed text-neutral-600 sm:text-lg dark:text-neutral-400">
            A stack of instant prints, tossed and overlapping. Point at any one
            and it straightens up, lifts off the pile and steps into focus.
          </p>
        </div>

        {/* Scatter board — absolute layout on lg, honest stacked grid below */}
        <div
          ref={boardRef}
          className="relative mx-auto grid grid-cols-2 gap-5 sm:grid-cols-3 lg:block lg:h-[760px] lg:gap-0"
        >
          {PHOTOS.map((photo, i) => {
            const isActive = active === i;
            const style: React.CSSProperties = {
              zIndex: isActive ? 50 : photo.z,
              ["--gps-l" as string]: photo.left,
              ["--gps-t" as string]: photo.top,
            };
            return (
              <motion.figure
                key={photo.src}
                custom={photo.rotate}
                variants={cardVariants}
                initial="hidden"
                animate={inView ? "show" : "hidden"}
                onHoverStart={() => setActive(i)}
                onHoverEnd={() => setActive((c) => (c === i ? null : c))}
                onFocus={() => setActive(i)}
                onBlur={() => setActive((c) => (c === i ? null : c))}
                tabIndex={0}
                aria-label={`${photo.title} — ${photo.caption}, ${photo.category}`}
                whileHover={
                  reduceMotion ? undefined : { rotate: 0, y: -16, scale: 1.05 }
                }
                whileFocus={
                  reduceMotion ? undefined : { rotate: 0, y: -16, scale: 1.05 }
                }
                transition={{ type: "spring", stiffness: 260, damping: 20 }}
                style={style}
                className="gps-card group relative mx-auto w-full max-w-[240px] cursor-pointer rounded-[3px] outline-none lg:w-[236px] lg:max-w-none"
              >
                <PolaroidCard photo={photo} active={isActive} />
              </motion.figure>
            );
          })}
        </div>

        <p className="mt-14 text-center text-sm text-neutral-500 sm:mt-20 dark:text-neutral-500">
          Nine of thirty-six frames. Tab through them with the keyboard, too.
        </p>
      </div>
    </section>
  );
}

function PolaroidCard({
  photo,
  active,
}: {
  photo: Polaroid;
  active: boolean;
}) {
  return (
    <div
      className={`relative rounded-[3px] bg-white p-3 pb-0 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45)] ring-1 ring-black/5 transition-shadow duration-300 dark:bg-neutral-100 ${
        active
          ? "shadow-[0_28px_60px_-18px_rgba(0,0,0,0.6)]"
          : "group-hover:shadow-[0_28px_60px_-18px_rgba(0,0,0,0.6)]"
      }`}
    >
      {/* strip of tape */}
      <span
        aria-hidden="true"
        className="absolute -top-3 left-1/2 h-6 w-20 -translate-x-1/2 -rotate-2 overflow-hidden bg-amber-200/70 shadow-sm ring-1 ring-amber-300/40 dark:bg-amber-300/50"
      >
        <span
          className={`gps-tape-sheen absolute inset-y-0 left-0 w-8 bg-white/70 ${
            active ? "" : "opacity-0"
          }`}
          style={{ animation: active ? "gps-tape-sheen 0.9s ease-out" : "none" }}
        />
      </span>

      <div className="relative overflow-hidden bg-neutral-200 dark:bg-neutral-300">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={photo.src}
          alt={`${photo.title} — ${photo.caption}`}
          loading="lazy"
          draggable={false}
          className="block aspect-square w-full object-cover"
        />
        {/* film gradient */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/15 via-transparent to-white/10"
        />
      </div>

      <figcaption className="px-1 py-3">
        <span className="block text-[11px] font-medium uppercase tracking-[0.18em] text-indigo-500 dark:text-indigo-600">
          {photo.category}
        </span>
        <span
          className="mt-0.5 block text-[17px] leading-tight text-neutral-800 dark:text-neutral-900"
          style={{ fontFamily: "ui-serif, Georgia, 'Times New Roman', serif" }}
        >
          {photo.title}
        </span>
        <span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-600">
          {photo.caption}
        </span>
      </figcaption>
    </div>
  );
}

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.

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.

Flip Cards Gallery

Flip Cards Gallery

Original

A grid of cards that flip in 3D on hover to reveal a caption and details on the back.