Web InnoventixFreeCode

Tilt Shine Gallery

Original · free

Image cards that tilt toward the cursor with a diagonal shine sweep on hover.

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

import type * as React from "react";
import { useCallback, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useMotionTemplate,
  useSpring,
} from "motion/react";

type Photo = {
  id: number;
  src: string;
  title: string;
  category: string;
  caption: string;
  location: string;
  span: "tall" | "wide" | "square";
};

const PHOTOS: Photo[] = [
  {
    id: 1,
    src: "/img/gallery/g03.webp",
    title: "Concrete Cathedral",
    category: "Architecture",
    caption: "Brutalist stairwell caught in low winter sun.",
    location: "Marseille, FR",
    span: "tall",
  },
  {
    id: 2,
    src: "/img/gallery/g11.webp",
    title: "Salt Flats at Dusk",
    category: "Landscape",
    caption: "Mirror pools stretch to a bruised horizon.",
    location: "Uyuni, BO",
    span: "wide",
  },
  {
    id: 3,
    src: "/img/gallery/g07.webp",
    title: "Market Blur",
    category: "Street",
    caption: "A vendor's hands, a hundred frames per second.",
    location: "Marrakech, MA",
    span: "square",
  },
  {
    id: 4,
    src: "/img/gallery/g19.webp",
    title: "Fern Understory",
    category: "Nature",
    caption: "First light filtering through old-growth canopy.",
    location: "Olympic NP, US",
    span: "tall",
  },
  {
    id: 5,
    src: "/img/gallery/g22.webp",
    title: "Neon After Rain",
    category: "Night",
    caption: "Reflections pooling on a slick crosswalk.",
    location: "Osaka, JP",
    span: "square",
  },
  {
    id: 6,
    src: "/img/gallery/g14.webp",
    title: "Dune Ridgeline",
    category: "Landscape",
    caption: "Wind-carved crests before the heat sets in.",
    location: "Sossusvlei, NA",
    span: "wide",
  },
  {
    id: 7,
    src: "/img/gallery/g28.webp",
    title: "Studio Portrait, No. 4",
    category: "Portrait",
    caption: "Single softbox, one honest expression.",
    location: "Berlin, DE",
    span: "tall",
  },
  {
    id: 8,
    src: "/img/gallery/g05.webp",
    title: "Harbor Cranes",
    category: "Industrial",
    caption: "Steel silhouettes against a flat grey sky.",
    location: "Rotterdam, NL",
    span: "square",
  },
  {
    id: 9,
    src: "/img/gallery/g31.webp",
    title: "Terraced Fields",
    category: "Landscape",
    caption: "Contours of a hillside farmed for centuries.",
    location: "Yuanyang, CN",
    span: "wide",
  },
];

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

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

  const rx = useSpring(useMotionValue(0), {
    stiffness: 220,
    damping: 22,
    mass: 0.6,
  });
  const ry = useSpring(useMotionValue(0), {
    stiffness: 220,
    damping: 22,
    mass: 0.6,
  });
  const glowX = useMotionValue(50);
  const glowY = useMotionValue(50);

  const handleMove = useCallback(
    (e: React.PointerEvent<HTMLButtonElement>) => {
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const px = (e.clientX - rect.left) / rect.width;
      const py = (e.clientY - rect.top) / rect.height;
      const MAX = 9;
      ry.set((px - 0.5) * 2 * MAX);
      rx.set((0.5 - py) * 2 * MAX);
      glowX.set(px * 100);
      glowY.set(py * 100);
    },
    [rx, ry, glowX, glowY],
  );

  const reset = useCallback(() => {
    rx.set(0);
    ry.set(0);
    glowX.set(50);
    glowY.set(50);
    setHovered(false);
  }, [rx, ry, glowX, glowY]);

  const glow = useMotionTemplate`radial-gradient(circle at ${glowX}% ${glowY}%, rgba(255,255,255,0.28), rgba(255,255,255,0) 55%)`;

  return (
    <motion.button
      ref={ref}
      type="button"
      aria-label={`${photo.title} — ${photo.category}, ${photo.location}`}
      onPointerMove={handleMove}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={reset}
      onBlur={reset}
      style={{ rotateX: rx, rotateY: ry, transformPerspective: 1000 }}
      className={`gts-card group relative block w-full overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-100 text-left shadow-sm outline-none transition-shadow duration-300 [transform-style:preserve-3d] hover:shadow-2xl hover:shadow-indigo-500/20 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-zinc-900 dark:hover:shadow-violet-500/25 dark:focus-visible:ring-violet-400 dark:focus-visible:ring-offset-zinc-950 ${spanClass[photo.span]}`}
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={photo.src}
        alt={`${photo.title} — ${photo.caption}`}
        loading="lazy"
        draggable={false}
        className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.06]"
        style={{ transform: "translateZ(0)" }}
      />

      {/* cursor-follow soft glow */}
      <motion.span
        aria-hidden
        style={{ backgroundImage: glow, opacity: hovered ? 1 : 0 }}
        className="pointer-events-none absolute inset-0 z-10 mix-blend-soft-light transition-opacity duration-300"
      />

      {/* diagonal shine sweep on hover */}
      <span
        aria-hidden
        className="gts-shine pointer-events-none absolute inset-0 z-20 opacity-0 group-hover:opacity-100"
      />

      {/* readability gradient */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 z-20 bg-gradient-to-t from-slate-950/85 via-slate-950/10 to-transparent"
      />

      {/* content */}
      <span
        className="absolute inset-x-0 bottom-0 z-30 flex translate-z-0 flex-col gap-2 p-4 sm:p-5"
        style={{ transform: "translateZ(40px)" }}
      >
        <span className="flex items-center gap-2">
          <span className="inline-flex items-center rounded-full border border-white/25 bg-white/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-white backdrop-blur-sm">
            {photo.category}
          </span>
          <span className="text-[11px] font-medium text-white/70">
            {photo.location}
          </span>
        </span>
        <span className="block text-lg font-semibold leading-tight text-white drop-shadow-sm sm:text-xl">
          {photo.title}
        </span>
        <span className="max-w-[46ch] translate-y-1 text-sm leading-snug text-white/80 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
          {photo.caption}
        </span>
      </span>

      {/* ring highlight */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 z-30 rounded-2xl ring-1 ring-inset ring-white/10"
      />

      <span className="sr-only">Photo {index + 1} of {PHOTOS.length}</span>
    </motion.button>
  );
}

export default function GalleryTiltShine() {
  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 lg:px-8 dark:bg-zinc-950 dark:text-slate-100">
      <style>{`
        @keyframes gts-sweep {
          0% { transform: translateX(-140%) skewX(-18deg); }
          100% { transform: translateX(140%) skewX(-18deg); }
        }
        .gts-shine::before {
          content: "";
          position: absolute;
          top: -20%;
          left: 0;
          height: 140%;
          width: 45%;
          background: linear-gradient(
            100deg,
            rgba(255,255,255,0) 0%,
            rgba(255,255,255,0.35) 45%,
            rgba(255,255,255,0.65) 50%,
            rgba(255,255,255,0.35) 55%,
            rgba(255,255,255,0) 100%
          );
          mix-blend-mode: overlay;
          transform: translateX(-140%) skewX(-18deg);
        }
        .gts-card:hover .gts-shine::before {
          animation: gts-sweep 900ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
        }
        @media (prefers-reduced-motion: reduce) {
          .gts-card { transform: none !important; }
          .gts-shine::before { animation: none !important; opacity: 0 !important; }
        }
      `}</style>

      {/* ambient backdrop */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10 opacity-70 [background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.indigo.200),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.violet.200),transparent)] dark:opacity-40 dark:[background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.indigo.600),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.violet.700),transparent)]"
      />

      <div className="mx-auto max-w-6xl">
        <header className="mb-12 flex flex-col gap-4 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-2xl">
            <p className="mb-3 inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.16em] text-slate-500 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-400">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Field Journal — 2026
            </p>
            <h2 className="text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-white">
              Frames from the road
            </h2>
            <p className="mt-4 text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-400">
              Move your cursor across each frame — the card leans toward you and
              a sheen of light sweeps the surface. A small collection of nine
              shots from this year&apos;s travels.
            </p>
          </div>
          <p className="shrink-0 text-sm font-medium text-slate-500 dark:text-slate-400">
            {PHOTOS.length} photographs
          </p>
        </header>

        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3">
          {PHOTOS.map((photo, i) => (
            <TiltCard key={photo.id} photo={photo} 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.

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.