Web InnoventixFreeCode

Magnetic Tiles Gallery

Original · free

Image tiles that magnetically nudge toward the cursor and scale slightly.

byWeb InnoventixReact + Tailwind
magnetictilesgalleryimages
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-magnetic-tiles.json
gallery-magnetic-tiles.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import {
  AnimatePresence,
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
} from "motion/react";

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

const TILES: Tile[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Weathered watchmaker inspecting a movement through a loupe at his bench",
    title: "The Watchmaker",
    category: "Portrait",
    location: "Geneva, CH",
    span: "tall",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "Snow-dusted alpine ridgeline under a pale winter sky",
    title: "Alpine Silence",
    category: "Landscape",
    location: "Zermatt, CH",
    span: "wide",
  },
  {
    src: "/img/gallery/g08.webp",
    alt: "Minimal still life of blue ceramics arranged on a stone shelf",
    title: "Still Life in Blue",
    category: "Studio",
    location: "Lisbon, PT",
    span: "square",
  },
  {
    src: "/img/gallery/g01.webp",
    alt: "Vaulted cathedral ceiling with light streaming through stained glass",
    title: "Cathedral of Light",
    category: "Architecture",
    location: "Cologne, DE",
    span: "tall",
  },
  {
    src: "/img/gallery/g04.webp",
    alt: "Rain-slicked alleyway lit by neon signage after dark",
    title: "Neon Alleyway",
    category: "Street",
    location: "Osaka, JP",
    span: "square",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Vendor surrounded by open sacks of saffron and spices at a market",
    title: "Saffron Market",
    category: "Travel",
    location: "Marrakech, MA",
    span: "wide",
  },
  {
    src: "/img/gallery/g06.webp",
    alt: "Brutalist concrete facade forming repeating geometric shadows",
    title: "Concrete Geometry",
    category: "Architecture",
    location: "São Paulo, BR",
    span: "tall",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Rolling sand dunes catching low golden light at dusk",
    title: "Dunes at Dusk",
    category: "Landscape",
    location: "Sossusvlei, NA",
    span: "wide",
  },
  {
    src: "/img/gallery/g10.webp",
    alt: "Figure with an umbrella crossing a rooftop in heavy rain",
    title: "Rooftop Rain",
    category: "Street",
    location: "Seoul, KR",
    span: "square",
  },
  {
    src: "/img/gallery/g02.webp",
    alt: "Long-exposure seascape of tide pulling over dark coastal rocks",
    title: "Morning Tide",
    category: "Seascape",
    location: "Nazaré, PT",
    span: "tall",
  },
];

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

function MagneticTile({
  tile,
  index,
  reduced,
  onOpen,
}: {
  tile: Tile;
  index: number;
  reduced: boolean;
  onOpen: (i: number) => void;
}) {
  const ref = useRef<HTMLButtonElement>(null);

  const springCfg = { stiffness: 220, damping: 18, mass: 0.4 };
  const x = useSpring(useMotionValue(0), springCfg);
  const y = useSpring(useMotionValue(0), springCfg);
  const scale = useSpring(1, { stiffness: 260, damping: 22 });
  const rotate = useSpring(0, { stiffness: 200, damping: 20 });

  const glowX = useMotionValue(50);
  const glowY = useMotionValue(50);
  const glow = useMotionTemplate`radial-gradient(220px circle at ${glowX}% ${glowY}%, rgba(255,255,255,0.22), transparent 62%)`;

  const handleMove = useCallback(
    (e: React.PointerEvent<HTMLButtonElement>) => {
      if (reduced) return;
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      const dx = e.clientX - cx;
      const dy = e.clientY - cy;
      x.set(dx * 0.16);
      y.set(dy * 0.16);
      rotate.set((dx / rect.width) * 4);
      glowX.set(((e.clientX - rect.left) / rect.width) * 100);
      glowY.set(((e.clientY - rect.top) / rect.height) * 100);
    },
    [reduced, x, y, rotate, glowX, glowY],
  );

  const handleEnter = useCallback(() => {
    if (reduced) return;
    scale.set(1.05);
  }, [reduced, scale]);

  const handleLeave = useCallback(() => {
    x.set(0);
    y.set(0);
    rotate.set(0);
    scale.set(1);
    glowX.set(50);
    glowY.set(50);
  }, [x, y, rotate, scale, glowX, glowY]);

  return (
    <motion.button
      ref={ref}
      type="button"
      onPointerMove={handleMove}
      onPointerEnter={handleEnter}
      onPointerLeave={handleLeave}
      onFocus={handleEnter}
      onBlur={handleLeave}
      onClick={() => onOpen(index)}
      style={reduced ? undefined : { x, y, scale, rotate }}
      initial={reduced ? false : { opacity: 0, y: 26 }}
      whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-60px" }}
      transition={{ duration: 0.5, delay: (index % 4) * 0.06, ease: [0.22, 1, 0.36, 1] }}
      aria-label={`Open ${tile.title}, ${tile.category}, ${tile.location}`}
      className={[
        "gmt-tile group relative overflow-hidden rounded-2xl text-left",
        "border border-slate-200/70 bg-slate-100 shadow-sm",
        "outline-none ring-indigo-500/60 transition-shadow duration-300",
        "hover:shadow-2xl hover:shadow-indigo-500/10 focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-zinc-900",
        "dark:shadow-black/40 dark:hover:shadow-black/60 dark:focus-visible:ring-offset-zinc-950",
        SPAN_CLASS[tile.span],
      ].join(" ")}
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={tile.src}
        alt={tile.alt}
        loading="lazy"
        draggable={false}
        className="gmt-img h-full w-full select-none object-cover transition-transform duration-[700ms] ease-out group-hover:scale-[1.08]"
      />

      {/* cursor spotlight */}
      {!reduced && (
        <motion.span
          aria-hidden
          style={{ backgroundImage: glow }}
          className="pointer-events-none absolute inset-0 opacity-0 mix-blend-soft-light transition-opacity duration-300 group-hover:opacity-100"
        />
      )}

      {/* legibility gradient */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent"
      />

      {/* caption */}
      <span className="pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-1 flex-col gap-1 p-4 transition-transform duration-300 group-hover:translate-y-0">
        <span className="flex items-center gap-2">
          <span className="inline-flex items-center rounded-full bg-white/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-white backdrop-blur-sm ring-1 ring-white/25">
            {tile.category}
          </span>
          <span className="text-[11px] font-medium text-white/70">{tile.location}</span>
        </span>
        <span className="text-base font-semibold leading-tight text-white drop-shadow-sm">
          {tile.title}
        </span>
      </span>

      {/* corner cue */}
      <span
        aria-hidden
        className="absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full bg-white/15 text-white opacity-0 backdrop-blur-md ring-1 ring-white/25 transition-all duration-300 group-hover:opacity-100"
      >
        <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4">
          <path
            d="M8 3H3v5m9-5h5v5m0 4v5h-5M8 17H3v-5"
            stroke="currentColor"
            strokeWidth="1.6"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </motion.button>
  );
}

export default function GalleryMagneticTiles() {
  const reduced = useReducedMotion() ?? false;
  const [active, setActive] = useState<number | null>(null);

  const open = useCallback((i: number) => setActive(i), []);
  const close = useCallback(() => setActive(null), []);
  const step = useCallback(
    (dir: 1 | -1) =>
      setActive((prev) =>
        prev === null ? prev : (prev + dir + TILES.length) % TILES.length,
      ),
    [],
  );

  useEffect(() => {
    if (active === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") step(1);
      else if (e.key === "ArrowLeft") step(-1);
    };
    window.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;
    };
  }, [active, close, step]);

  const current = active === null ? null : TILES[active];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-zinc-950 dark:text-slate-100">
      <style>{`
        @keyframes gmt-float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -14px, 0); }
        }
        @keyframes gmt-pop {
          from { opacity: 0; transform: scale(0.94); }
          to { opacity: 1; transform: scale(1); }
        }
        .gmt-orb { animation: gmt-float 12s ease-in-out infinite; }
        .gmt-orb-2 { animation: gmt-float 15s ease-in-out infinite reverse; }
        @media (prefers-reduced-motion: reduce) {
          .gmt-orb, .gmt-orb-2 { animation: none; }
          .gmt-img { transition: none !important; }
        }
      `}</style>

      {/* ambient background */}
      <div
        aria-hidden
        className="gmt-orb pointer-events-none absolute -left-24 top-10 -z-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden
        className="gmt-orb-2 pointer-events-none absolute -right-16 bottom-0 -z-10 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/15"
      />

      <div className="mx-auto max-w-6xl">
        <header className="mb-12 flex flex-col 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-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-[0.16em] text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Field Notes 2026
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">
              The Magnetic Archive
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Ten frames from the road. Move your cursor across the grid, the tiles
              lean toward you. Click any frame to open it full-size.
            </p>
          </div>
          <p className="shrink-0 text-sm text-slate-500 dark:text-slate-500">
            <span className="font-semibold text-slate-800 dark:text-slate-200">
              {TILES.length}
            </span>{" "}
            selected works
          </p>
        </header>

        <div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
          {TILES.map((tile, i) => (
            <MagneticTile
              key={tile.src}
              tile={tile}
              index={i}
              reduced={reduced}
              onOpen={open}
            />
          ))}
        </div>
      </div>

      {/* lightbox */}
      <AnimatePresence>
        {current && (
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-label={`${current.title}, ${current.category}`}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/90 p-4 backdrop-blur-md sm:p-8"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.22 }}
            onClick={close}
          >
            <button
              type="button"
              onClick={close}
              aria-label="Close"
              className="absolute right-4 top-4 z-10 grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2"
            >
              <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
                <path
                  d="M6 6l12 12M18 6L6 18"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                />
              </svg>
            </button>

            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                step(-1);
              }}
              aria-label="Previous image"
              className="absolute left-3 top-1/2 z-10 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2 sm:left-6"
            >
              <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
                <path
                  d="M14 6l-6 6 6 6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>

            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                step(1);
              }}
              aria-label="Next image"
              className="absolute right-3 top-1/2 z-10 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2 sm:right-6"
            >
              <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
                <path
                  d="M10 6l6 6-6 6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>

            <motion.figure
              key={current.src}
              onClick={(e) => e.stopPropagation()}
              initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
              transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
              className="flex max-h-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-zinc-900 shadow-2xl ring-1 ring-white/10"
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={current.src}
                alt={current.alt}
                loading="lazy"
                draggable={false}
                className="max-h-[70vh] w-full select-none object-contain"
              />
              <figcaption className="flex items-center justify-between gap-4 border-t border-white/10 px-5 py-4">
                <div>
                  <p className="text-sm font-semibold text-white">{current.title}</p>
                  <p className="text-xs text-slate-400">
                    {current.category} · {current.location}
                  </p>
                </div>
                <span className="text-xs tabular-nums text-slate-500">
                  {String((active ?? 0) + 1).padStart(2, "0")} / {String(TILES.length).padStart(2, "0")}
                </span>
              </figcaption>
            </motion.figure>
          </motion.div>
        )}
      </AnimatePresence>
    </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.