Web InnoventixFreeCode

Flip Cards Gallery

Original · free

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

byWeb InnoventixReact + Tailwind
flipcardsgalleryimages
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-flip-cards.json
gallery-flip-cards.tsx
"use client";

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

type FlipItem = {
  src: string;
  alt: string;
  title: string;
  category: string;
  location: string;
  year: string;
  detail: string;
  accent: string;
};

const ITEMS: FlipItem[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Pale morning fog rolling over dark coastal cliffs at first light",
    title: "Coastal Light",
    category: "Landscape",
    location: "Big Sur, CA",
    year: "2024",
    detail: "Shot 40 minutes before sunrise while the marine layer still hugged the rocks.",
    accent: "from-sky-400 to-indigo-500",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Studio portrait lit with a single soft key light against deep shadow",
    title: "Studio 04",
    category: "Portrait",
    location: "Berlin",
    year: "2023",
    detail: "One 5-foot octabox, feathered to keep the falloff long and quiet.",
    accent: "from-rose-400 to-fuchsia-500",
  },
  {
    src: "/img/gallery/g12.webp",
    alt: "Close detail of layered green leaves catching a thin band of sunlight",
    title: "Understory",
    category: "Nature",
    location: "Kyoto",
    year: "2024",
    detail: "Backlit foliage after rain, focus stacked across three frames.",
    accent: "from-emerald-400 to-teal-500",
  },
  {
    src: "/img/gallery/g18.webp",
    alt: "Minimal architecture with a curved concrete wall and hard midday shadow",
    title: "Concrete Curve",
    category: "Architecture",
    location: "Lisbon",
    year: "2022",
    detail: "Waited for the sun to clip the parapet and split the wall in two.",
    accent: "from-amber-400 to-orange-500",
  },
  {
    src: "/img/gallery/g22.webp",
    alt: "Quiet city street at dusk with warm shop windows and wet pavement",
    title: "Blue Hour",
    category: "Street",
    location: "Tokyo",
    year: "2023",
    detail: "Handheld at 1/15s, leaning on a lamppost to steady the frame.",
    accent: "from-indigo-400 to-violet-500",
  },
  {
    src: "/img/gallery/g27.webp",
    alt: "Overhead flat lay of ceramics and dried botanicals on linen",
    title: "Still Life No.9",
    category: "Product",
    location: "Studio",
    year: "2024",
    detail: "North-facing window light, a single black flag on the shadow side.",
    accent: "from-stone-400 to-neutral-500",
  },
  {
    src: "/img/gallery/g31.webp",
    alt: "Long exposure of a mountain river smoothing over dark boulders",
    title: "Slow Water",
    category: "Landscape",
    location: "Torres del Paine",
    year: "2023",
    detail: "Six-stop ND, thirty-second exposure to soften the current.",
    accent: "from-cyan-400 to-blue-500",
  },
  {
    src: "/img/gallery/g34.webp",
    alt: "Candid portrait of a musician backstage under a bare warm bulb",
    title: "Backstage",
    category: "Documentary",
    location: "New Orleans",
    year: "2022",
    detail: "Available light only, pushed two stops to keep the mood intact.",
    accent: "from-yellow-400 to-red-500",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Abstract pattern of rippling sand dunes in raking evening light",
    title: "Dune Study",
    category: "Abstract",
    location: "Namib Desert",
    year: "2024",
    detail: "Shot long, from a ridge, to compress the ridgelines into ribbons.",
    accent: "from-orange-400 to-rose-500",
  },
];

function FlipCard({ item, index }: { item: FlipItem; index: number }) {
  const [flipped, setFlipped] = useState(false);

  return (
    <motion.li
      initial={{ opacity: 0, y: 28 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: 0.55,
        delay: index * 0.06,
        ease: [0.22, 0.8, 0.2, 1],
      }}
      className="gfc-scene group relative"
    >
      <button
        type="button"
        onClick={() => setFlipped((v) => !v)}
        aria-pressed={flipped}
        aria-label={`${item.title}. ${item.category}. Activate to ${
          flipped ? "hide" : "reveal"
        } details.`}
        className="block w-full rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950"
      >
        <div
          className="gfc-card relative aspect-[4/5] w-full rounded-2xl"
          data-flipped={flipped}
        >
          {/* Front */}
          <div className="gfc-face absolute inset-0 overflow-hidden rounded-2xl border border-neutral-200/80 bg-neutral-100 shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-neutral-900 dark:ring-white/5">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={item.src}
              alt={item.alt}
              loading="lazy"
              draggable={false}
              className="h-full w-full select-none object-cover transition-transform duration-700 ease-out will-change-transform group-hover:scale-[1.04]"
            />
            <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/10 to-transparent" />
            <div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
              <div>
                <span
                  className={`inline-flex rounded-full bg-gradient-to-r ${item.accent} px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white shadow`}
                >
                  {item.category}
                </span>
                <h3 className="mt-2 text-lg font-semibold leading-tight text-white drop-shadow-sm">
                  {item.title}
                </h3>
              </div>
              <span className="mb-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/15 text-white backdrop-blur-sm ring-1 ring-white/25 transition-colors group-hover:bg-white/25">
                <svg
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                >
                  <path d="M4 4v6h6" />
                  <path d="M20 20v-6h-6" />
                  <path d="M20 10a8 8 0 0 0-14.9-3M4 14a8 8 0 0 0 14.9 3" />
                </svg>
              </span>
            </div>
          </div>

          {/* Back */}
          <div className="gfc-face gfc-back absolute inset-0 flex flex-col overflow-hidden rounded-2xl border border-neutral-200 bg-white p-5 text-left shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-neutral-900 dark:ring-white/5">
            <div
              className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${item.accent}`}
            />
            <span className="text-[11px] font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
              {item.category}
            </span>
            <h3 className="mt-1 text-xl font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
              {item.title}
            </h3>
            <p className="mt-3 text-sm leading-relaxed text-neutral-600 dark:text-neutral-300">
              {item.detail}
            </p>
            <dl className="mt-auto grid grid-cols-2 gap-3 border-t border-neutral-200 pt-4 dark:border-white/10">
              <div>
                <dt className="text-[11px] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
                  Location
                </dt>
                <dd className="mt-0.5 text-sm font-medium text-neutral-800 dark:text-neutral-100">
                  {item.location}
                </dd>
              </div>
              <div>
                <dt className="text-[11px] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
                  Year
                </dt>
                <dd className="mt-0.5 text-sm font-medium text-neutral-800 dark:text-neutral-100">
                  {item.year}
                </dd>
              </div>
            </dl>
          </div>
        </div>
      </button>
    </motion.li>
  );
}

export default function GalleryFlipCards() {
  const sectionRef = useRef<HTMLElement | null>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-white px-6 py-20 sm:py-24 dark:bg-neutral-950"
    >
      <style>{`
        .gfc-scene { perspective: 1600px; }
        .gfc-card {
          transform-style: preserve-3d;
          transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
        }
        .gfc-scene:hover .gfc-card,
        .gfc-scene:focus-within .gfc-card,
        .gfc-card[data-flipped="true"] {
          transform: rotateY(180deg);
        }
        .gfc-face {
          -webkit-backface-visibility: hidden;
          backface-visibility: hidden;
        }
        .gfc-back { transform: rotateY(180deg); }
        @keyframes gfc-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -14px, 0); }
        }
        .gfc-orb { animation: gfc-drift 9s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gfc-card { transition: none; }
          .gfc-orb { animation: none; }
        }
      `}</style>

      {/* Ambient background */}
      <div
        aria-hidden="true"
        className="gfc-orb pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-300/40 to-violet-300/30 blur-3xl dark:from-indigo-600/20 dark:to-violet-600/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-32 -left-24 h-80 w-80 rounded-full bg-gradient-to-br from-emerald-300/30 to-sky-300/20 blur-3xl dark:from-emerald-600/15 dark:to-sky-600/10"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="mx-auto max-w-2xl text-center">
          <motion.span
            initial={{ opacity: 0, y: 12 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.5 }}
            className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-neutral-50 px-3 py-1 text-xs font-medium text-neutral-600 dark:border-white/10 dark:bg-white/5 dark:text-neutral-300"
          >
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Selected Work
          </motion.span>
          <motion.h2
            initial={{ opacity: 0, y: 16 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.55, delay: 0.05 }}
            className="mt-4 text-balance text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50"
          >
            A collection that turns over
          </motion.h2>
          <motion.p
            initial={{ opacity: 0, y: 16 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.55, delay: 0.1 }}
            className="mt-3 text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400"
          >
            Hover, tap, or focus any frame to flip it and read the story behind
            the shot.
          </motion.p>
        </header>

        <ul className="mt-14 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {ITEMS.map((item, i) => (
            <FlipCard key={item.src} item={item} index={i} />
          ))}
        </ul>
      </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.