Web InnoventixFreeCode

Tilt Cards Gallery

Original · free

A grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

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

import { useRef, useState } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";

type Shot = {
  src: string;
  title: string;
  category: string;
  location: string;
  span: "tall" | "wide" | "square";
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal Light",
    category: "Landscape",
    location: "Cinque Terre, IT",
    span: "tall",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Studio 04",
    category: "Portrait",
    location: "Berlin Atelier",
    span: "square",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Low Tide",
    category: "Nature",
    location: "Skagen Shore",
    span: "wide",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Marble & Fog",
    category: "Still Life",
    location: "Carrara Quarry",
    span: "tall",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Terracotta Hour",
    category: "Architecture",
    location: "Marrakech Medina",
    span: "square",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "Northbound",
    category: "Travel",
    location: "Lofoten Islands",
    span: "wide",
  },
  {
    src: "/img/gallery/g29.webp",
    title: "Quiet Bloom",
    category: "Botanical",
    location: "Kyoto Greenhouse",
    span: "tall",
  },
  {
    src: "/img/gallery/g05.webp",
    title: "Concrete Poem",
    category: "Urban",
    location: "São Paulo",
    span: "square",
  },
  {
    src: "/img/gallery/g33.webp",
    title: "Salt Flats",
    category: "Landscape",
    location: "Uyuni, BO",
    span: "wide",
  },
];

const SPAN_CLASS: Record<Shot["span"], string> = {
  tall: "sm:row-span-2 aspect-[4/5]",
  wide: "sm:col-span-2 aspect-[16/10]",
  square: "aspect-square",
};

function TiltCard({ shot, index }: { shot: Shot; index: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const [hovered, setHovered] = useState(false);

  // Normalized pointer position within the card, range -0.5 .. 0.5
  const px = useMotionValue(0);
  const py = useMotionValue(0);

  const springCfg = { stiffness: 220, damping: 22, mass: 0.6 };
  const rotateX = useSpring(
    useTransform(py, [-0.5, 0.5], [9, -9]),
    springCfg,
  );
  const rotateY = useSpring(
    useTransform(px, [-0.5, 0.5], [-11, 11]),
    springCfg,
  );

  // Glare follows the pointer, composed into a live radial-gradient string
  const glareX = useTransform(px, [-0.5, 0.5], ["0%", "100%"]);
  const glareY = useTransform(py, [-0.5, 0.5], ["0%", "100%"]);
  const glare = useTransform(
    [glareX, glareY],
    ([gx, gy]: string[]) =>
      `radial-gradient(closest-side circle at ${gx} ${gy}, rgba(255,255,255,0.55), rgba(255,255,255,0) 70%)`,
  );

  function handleMove(e: React.PointerEvent<HTMLDivElement>) {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    px.set((e.clientX - rect.left) / rect.width - 0.5);
    py.set((e.clientY - rect.top) / rect.height - 0.5);
  }

  function reset() {
    setHovered(false);
    px.set(0);
    py.set(0);
  }

  return (
    <motion.div
      className={`gtc-card group relative ${SPAN_CLASS[shot.span]}`}
      style={{ perspective: 1000 }}
      initial={{ opacity: 0, y: 26 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-60px" }}
      transition={{
        duration: 0.6,
        delay: (index % 3) * 0.08 + Math.floor(index / 3) * 0.05,
        ease: [0.22, 1, 0.36, 1],
      }}
    >
      <motion.div
        ref={ref}
        role="group"
        tabIndex={0}
        aria-label={`${shot.title} — ${shot.category}, ${shot.location}`}
        onPointerMove={handleMove}
        onPointerEnter={() => setHovered(true)}
        onPointerLeave={reset}
        onBlur={reset}
        style={{
          rotateX,
          rotateY,
          transformStyle: "preserve-3d",
        }}
        className="relative h-full w-full cursor-pointer overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-1 ring-inset ring-black/5 transition-shadow duration-500 [box-shadow:0_1px_2px_rgba(15,23,42,0.08),0_12px_30px_-16px_rgba(15,23,42,0.35)] hover:[box-shadow:0_2px_6px_rgba(15,23,42,0.12),0_40px_60px_-24px_rgba(15,23,42,0.55)] focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-800 dark:ring-white/10 dark:hover:[box-shadow:0_2px_6px_rgba(0,0,0,0.5),0_40px_70px_-24px_rgba(0,0,0,0.8)] dark:focus-visible:ring-indigo-400"
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={shot.src}
          alt={`${shot.title}, a ${shot.category.toLowerCase()} photograph taken in ${shot.location}`}
          loading="lazy"
          draggable={false}
          className="absolute inset-0 h-full w-full scale-[1.06] object-cover transition-transform duration-700 ease-out group-hover:scale-[1.12]"
          style={{ transform: "translateZ(0)" }}
        />

        {/* Depth vignette */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/75 via-neutral-950/5 to-transparent"
        />

        {/* Soft glare that tracks the cursor */}
        <motion.div
          aria-hidden
          className="pointer-events-none absolute -inset-8 opacity-0 mix-blend-soft-light transition-opacity duration-300 group-hover:opacity-100"
          style={{ background: glare }}
        />

        {/* Fine grain / sheen edge */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-white/15"
        />

        {/* Caption — lifted forward in 3D space */}
        <motion.div
          style={{ transform: "translateZ(46px)" }}
          className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-1 p-4 sm:p-5"
        >
          <span className="w-fit rounded-full bg-white/15 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-white backdrop-blur-md ring-1 ring-inset ring-white/25">
            {shot.category}
          </span>
          <div className="mt-1.5 flex items-end justify-between gap-3">
            <h3 className="font-serif text-lg leading-tight text-white drop-shadow-sm sm:text-xl">
              {shot.title}
            </h3>
            <span
              className={`shrink-0 text-xs font-medium text-white/70 transition-all duration-500 ${
                hovered
                  ? "translate-y-0 opacity-100"
                  : "translate-y-1 opacity-0"
              }`}
            >
              {shot.location}
            </span>
          </div>
        </motion.div>
      </motion.div>
    </motion.div>
  );
}

export default function GalleryTiltCards() {
  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-neutral-50 via-white to-neutral-100 px-5 py-20 text-neutral-900 sm:px-8 sm:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black dark:text-neutral-50">
      <style>{`
        @keyframes gtc-float {
          0%, 100% { transform: translate(0, 0) scale(1); }
          50% { transform: translate(-3%, 4%) scale(1.08); }
        }
        .gtc-orb { animation: gtc-float 18s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gtc-orb { animation: none; }
          .gtc-card { transition: none !important; }
        }
      `}</style>

      {/* Atmosphere */}
      <div
        aria-hidden
        className="gtc-orb pointer-events-none absolute -left-24 top-10 h-80 w-80 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden
        className="gtc-orb pointer-events-none absolute -right-20 bottom-0 h-96 w-96 rounded-full bg-emerald-200/30 blur-3xl [animation-delay:-9s] dark:bg-violet-700/15"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-[0.04] [background-image:radial-gradient(circle_at_1px_1px,currentColor_1px,transparent_0)] [background-size:22px_22px]"
      />

      <div className="relative mx-auto max-w-6xl">
        {/* Header */}
        <div className="mb-12 flex flex-col items-start gap-5 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-neutral-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-neutral-500 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Field Notebook
            </span>
            <h2 className="mt-4 font-serif text-4xl leading-[1.05] tracking-tight sm:text-5xl">
              Frames that lean
              <span className="italic text-indigo-600 dark:text-indigo-400">
                {" "}
                toward you
              </span>
            </h2>
            <p className="mt-4 max-w-md text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
              A hand-picked set of nine. Move your cursor across a frame and it
              tips into the light — glass catching a soft glare, the caption
              rising forward.
            </p>
          </div>
          <p className="text-sm font-medium text-neutral-400 dark:text-neutral-500">
            09 / archive
          </p>
        </div>

        {/* Grid */}
        <div className="grid auto-rows-[minmax(0,1fr)] grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3">
          {SHOTS.map((shot, i) => (
            <TiltCard key={shot.src} shot={shot} index={i} />
          ))}
        </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.

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.

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.