Web InnoventixFreeCode

Diagonal Grid Gallery

Original · free

A skewed, diagonal image grid whose tiles straighten and zoom on hover.

byWeb InnoventixReact + Tailwind
diagonalgridgalleryimages
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-diagonal-grid.json
gallery-diagonal-grid.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";

type Orientation = "portrait" | "landscape" | "square";

type Tile = {
  src: string;
  title: string;
  category: string;
  caption: string;
  orientation: Orientation;
};

const TILES: Tile[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Atrium, First Light",
    category: "Architecture",
    caption: "6:12am — the east glazing catches the day before the street does.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Salt Flats, Km 214",
    category: "Landscape",
    caption: "A horizon so flat the sky forgets where it started.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Ceramicist's Hands",
    category: "Craft",
    caption: "Wet clay, forty years of muscle memory, one honest bowl.",
    orientation: "square",
  },
  {
    src: "/img/gallery/g19.webp",
    title: "Stairwell No. 4",
    category: "Architecture",
    caption: "Concrete spiralling down into its own shadow.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g24.webp",
    title: "Harbour Fog",
    category: "Landscape",
    caption: "Cranes fading out one grey silhouette at a time.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g15.webp",
    title: "Market, 5th Aisle",
    category: "Street",
    caption: "Persimmons stacked like a small orange argument.",
    orientation: "square",
  },
  {
    src: "/img/gallery/g28.webp",
    title: "Reeds at Dusk",
    category: "Nature",
    caption: "Wind writing the same sentence over and over.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g31.webp",
    title: "Bridge Underpass",
    category: "Architecture",
    caption: "Rivets, rust, and the low hum of traffic overhead.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Studio Portrait, Ilse",
    category: "Portrait",
    caption: "One softbox, one window, and a face that needed neither.",
    orientation: "square",
  },
  {
    src: "/img/gallery/g09.webp",
    title: "Dune Ridge",
    category: "Landscape",
    caption: "The desert keeps its edges sharp until the sun leaves.",
    orientation: "portrait",
  },
];

const SPAN: Record<Orientation, string> = {
  portrait: "row-[span_5] sm:row-[span_5]",
  landscape: "col-[span_2] row-[span_3]",
  square: "row-[span_4]",
};

export default function GalleryDiagonalGrid() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-12% 0px" });
  const reduce = useReducedMotion();
  const prefix = useId().replace(/[:]/g, "");

  const [open, setOpen] = useState<number | null>(null);
  const closeBtnRef = useRef<HTMLButtonElement>(null);

  const close = useCallback(() => setOpen(null), []);
  const go = useCallback((dir: 1 | -1) => {
    setOpen((cur) => {
      if (cur === null) return cur;
      return (cur + dir + TILES.length) % TILES.length;
    });
  }, []);

  useEffect(() => {
    if (open === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") go(1);
      else if (e.key === "ArrowLeft") go(-1);
    };
    window.addEventListener("keydown", onKey);
    const t = window.setTimeout(() => closeBtnRef.current?.focus(), 0);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      window.clearTimeout(t);
      document.body.style.overflow = prevOverflow;
    };
  }, [open, close, go]);

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

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:from-zinc-950 dark:via-zinc-900 dark:to-black dark:text-zinc-50">
      <style>{`
        @keyframes ${prefix}-rise {
          from { opacity: 0; transform: translateY(28px) rotate(-6deg) scale(0.94); }
          to   { opacity: 1; transform: translateY(0) rotate(-6deg) scale(1); }
        }
        @keyframes ${prefix}-sheen {
          0%   { transform: translateX(-120%) rotate(6deg); opacity: 0; }
          18%  { opacity: 0.55; }
          60%  { opacity: 0; }
          100% { transform: translateX(240%) rotate(6deg); opacity: 0; }
        }
        .${prefix}-tile {
          transition:
            transform 620ms cubic-bezier(0.16, 1, 0.3, 1),
            box-shadow 620ms cubic-bezier(0.16, 1, 0.3, 1),
            z-index 0ms;
          will-change: transform;
        }
        .${prefix}-img {
          transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), filter 620ms ease;
        }
        .${prefix}-meta {
          transition: opacity 500ms ease, transform 620ms cubic-bezier(0.16, 1, 0.3, 1);
        }
        .${prefix}-group:hover .${prefix}-tile,
        .${prefix}-group:focus-within .${prefix}-tile {
          transform: rotate(12deg) scale(1.06) !important;
          z-index: 30;
          box-shadow:
            0 30px 60px -18px rgba(15, 23, 42, 0.45),
            0 8px 20px -8px rgba(79, 70, 229, 0.35);
        }
        .${prefix}-group:hover .${prefix}-img,
        .${prefix}-group:focus-within .${prefix}-img {
          transform: scale(1.14);
          filter: saturate(1.08) contrast(1.03);
        }
        .${prefix}-group:hover .${prefix}-meta,
        .${prefix}-group:focus-within .${prefix}-meta {
          opacity: 1;
          transform: translateY(0);
        }
        @media (prefers-reduced-motion: reduce) {
          .${prefix}-tile,
          .${prefix}-img,
          .${prefix}-meta { transition-duration: 1ms !important; }
          .${prefix}-group:hover .${prefix}-tile,
          .${prefix}-group:focus-within .${prefix}-tile {
            transform: rotate(6deg) !important;
          }
          .${prefix}-sheen { display: none !important; }
          [data-gdg-rise] { animation: none !important; opacity: 1 !important; }
        }
      `}</style>

      {/* ambient diagonal glow */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10 opacity-70"
      >
        <div className="absolute -left-24 top-10 h-80 w-80 -rotate-12 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute -right-16 bottom-0 h-96 w-96 rotate-12 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
      </div>

      <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-xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Field Journal · Vol. 07
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">
              The Diagonal Series
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
              Ten frames pinned at an angle. Hover any tile and it snaps
              upright, leans forward, and shows its story.
            </p>
          </div>
          <p className="shrink-0 text-sm text-slate-500 dark:text-zinc-500">
            {TILES.length} photographs · 2024–2026
          </p>
        </header>

        {/* skewed stage — the grid is rotated as a whole; tiles counter-rotate on hover */}
        <div className="relative -mx-2 overflow-visible px-2 py-6">
          <div
            ref={sectionRef}
            className="grid auto-rows-[46px] grid-cols-2 gap-4 sm:auto-rows-[54px] sm:grid-cols-3 sm:gap-5 lg:grid-cols-4"
            style={{ transform: "rotate(-6deg)", transformOrigin: "center" }}
          >
            {TILES.map((t, i) => {
              const delay = 90 * i;
              const isOpen = open === i;
              return (
                <div
                  key={t.src}
                  className={`${prefix}-group group relative ${SPAN[t.orientation]}`}
                  data-gdg-rise=""
                  style={{
                    animation:
                      inView && !reduce
                        ? `${prefix}-rise 760ms cubic-bezier(0.16,1,0.3,1) ${delay}ms both`
                        : undefined,
                    opacity: inView || reduce ? undefined : 0,
                    transform: "rotate(-6deg)",
                  }}
                >
                  <button
                    type="button"
                    onClick={() => setOpen(i)}
                    aria-label={`Open “${t.title}”, ${t.category}`}
                    aria-haspopup="dialog"
                    aria-expanded={isOpen}
                    className={`${prefix}-tile relative block h-full w-full overflow-hidden rounded-2xl bg-slate-200 text-left shadow-lg shadow-slate-900/10 outline-none ring-indigo-500 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-800 dark:shadow-black/40 dark:ring-offset-zinc-950`}
                    style={{ transform: "rotate(6deg) scale(1)" }}
                  >
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={t.src}
                      alt={`${t.title} — ${t.caption}`}
                      loading="lazy"
                      draggable={false}
                      className={`${prefix}-img h-full w-full select-none object-cover`}
                    />

                    {/* sheen swipe */}
                    <span
                      aria-hidden
                      className={`${prefix}-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/70 to-transparent`}
                      style={{
                        animation: reduce
                          ? undefined
                          : `${prefix}-sheen 2.8s ease-in-out ${1.2 + i * 0.15}s infinite`,
                      }}
                    />

                    {/* gradient scrim + meta */}
                    <span
                      aria-hidden
                      className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent opacity-90"
                    />
                    <span
                      className={`${prefix}-meta pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-2 flex-col gap-1 p-3 opacity-0 sm:p-4`}
                    >
                      <span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
                        {t.category}
                      </span>
                      <span className="text-sm font-semibold leading-tight text-white sm:text-base">
                        {t.title}
                      </span>
                    </span>
                  </button>
                </div>
              );
            })}
          </div>
        </div>

        <p className="mt-14 text-center text-xs text-slate-400 dark:text-zinc-600">
          Tip: use Tab to focus a frame, Enter to open. Arrow keys move between
          photos in the viewer.
        </p>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {active && (
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-label={`${active.title}, ${active.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: reduce ? 0 : 0.24 }}
            onClick={close}
          >
            <motion.figure
              key={active.src}
              className="relative flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-zinc-900"
              initial={{ scale: reduce ? 1 : 0.92, y: reduce ? 0 : 16, opacity: 0 }}
              animate={{ scale: 1, y: 0, opacity: 1 }}
              exit={{ scale: reduce ? 1 : 0.96, opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
              onClick={(e) => e.stopPropagation()}
            >
              <div className="relative bg-slate-100 dark:bg-black">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={active.src}
                  alt={`${active.title} — ${active.caption}`}
                  loading="lazy"
                  draggable={false}
                  className="mx-auto max-h-[68vh] w-auto max-w-full select-none object-contain"
                />

                <button
                  ref={closeBtnRef}
                  type="button"
                  onClick={close}
                  aria-label="Close viewer"
                  className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
                >
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
                    <path
                      d="M6 6l12 12M18 6L6 18"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>

                <button
                  type="button"
                  onClick={() => go(-1)}
                  aria-label="Previous photo"
                  className="absolute left-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
                >
                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
                    <path d="M15 5l-7 7 7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
                <button
                  type="button"
                  onClick={() => go(1)}
                  aria-label="Next photo"
                  className="absolute right-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
                >
                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
                    <path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              </div>

              <figcaption className="flex items-start justify-between gap-4 border-t border-slate-200 p-5 dark:border-zinc-800">
                <div>
                  <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
                    {active.category}
                  </p>
                  <h3 className="mt-1 text-lg font-semibold text-slate-900 dark:text-zinc-50">
                    {active.title}
                  </h3>
                  <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
                    {active.caption}
                  </p>
                </div>
                <span className="shrink-0 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400">
                  {String((open ?? 0) + 1).padStart(2, "0")} / {TILES.length}
                </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.