Web InnoventixFreeCode

Bento Gallery

Original · free

A bento-style gallery mixing large and small image tiles in an asymmetric grid, each with a hover zoom.

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

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

type Tile = {
  id: number;
  src: string;
  alt: string;
  title: string;
  category: string;
  /** Tailwind grid span classes controlling the asymmetric bento layout. */
  span: string;
  aspect: "portrait" | "landscape" | "square";
};

const TILES: Tile[] = [
  {
    id: 1,
    src: "/img/gallery/g04.webp",
    alt: "Sunlight breaking over a quiet coastal shoreline at dawn",
    title: "Coastal Light",
    category: "Landscape",
    span: "sm:col-span-2 sm:row-span-2",
    aspect: "square",
  },
  {
    id: 2,
    src: "/img/gallery/g11.webp",
    alt: "Close portrait lit by soft window light in a minimal studio",
    title: "Studio 04",
    category: "Portrait",
    span: "sm:col-span-1 sm:row-span-2",
    aspect: "portrait",
  },
  {
    id: 3,
    src: "/img/gallery/g19.webp",
    alt: "Dense fern canopy in a rainforest under filtered green light",
    title: "Undergrowth",
    category: "Nature",
    span: "sm:col-span-1 sm:row-span-1",
    aspect: "square",
  },
  {
    id: 4,
    src: "/img/gallery/g27.webp",
    alt: "Long empty highway cutting through open desert at golden hour",
    title: "Route 12",
    category: "Travel",
    span: "sm:col-span-2 sm:row-span-1",
    aspect: "landscape",
  },
  {
    id: 5,
    src: "/img/gallery/g02.webp",
    alt: "Overhead flat-lay of ceramics arranged on a linen surface",
    title: "Slow Craft",
    category: "Still Life",
    span: "sm:col-span-1 sm:row-span-1",
    aspect: "square",
  },
  {
    id: 6,
    src: "/img/gallery/g33.webp",
    alt: "Glass tower facade reflecting drifting afternoon clouds",
    title: "Mirror District",
    category: "Architecture",
    span: "sm:col-span-2 sm:row-span-2",
    aspect: "landscape",
  },
  {
    id: 7,
    src: "/img/gallery/g08.webp",
    alt: "Mountain ridge line fading into layered morning mist",
    title: "Blue Ridges",
    category: "Landscape",
    span: "sm:col-span-1 sm:row-span-1",
    aspect: "square",
  },
  {
    id: 8,
    src: "/img/gallery/g22.webp",
    alt: "Profile portrait against a deep shadowed backdrop",
    title: "Studio 09",
    category: "Portrait",
    span: "sm:col-span-1 sm:row-span-2",
    aspect: "portrait",
  },
  {
    id: 9,
    src: "/img/gallery/g15.webp",
    alt: "Waves folding over dark volcanic sand in the late evening",
    title: "Tide Study",
    category: "Nature",
    span: "sm:col-span-2 sm:row-span-1",
    aspect: "landscape",
  },
  {
    id: 10,
    src: "/img/gallery/g30.webp",
    alt: "Neon-lit alley in the rain with reflections on wet pavement",
    title: "After Hours",
    category: "Street",
    span: "sm:col-span-1 sm:row-span-1",
    aspect: "square",
  },
];

function Tile({
  tile,
  index,
  onOpen,
}: {
  tile: Tile;
  index: number;
  onOpen: (index: number) => void;
}) {
  const ref = useRef<HTMLButtonElement>(null);
  const inView = useInView(ref, { once: true, margin: "-60px" });

  return (
    <motion.button
      ref={ref}
      type="button"
      onClick={() => onOpen(index)}
      aria-label={`Open ${tile.title}, ${tile.category}`}
      initial={{ opacity: 0, y: 26, scale: 0.97 }}
      animate={inView ? { opacity: 1, y: 0, scale: 1 } : undefined}
      transition={{ duration: 0.55, delay: index * 0.05, ease: [0.22, 1, 0.36, 1] }}
      className={`group relative overflow-hidden rounded-2xl bg-slate-100 outline-none ring-slate-900/10 dark:bg-slate-800 dark:ring-white/10 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${tile.span}`}
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={tile.src}
        alt={tile.alt}
        loading="lazy"
        draggable={false}
        className="h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.08]"
      />

      {/* readability gradient */}
      <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/5 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-95" />

      {/* corner label */}
      <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-3 sm:p-4">
        <div className="translate-y-1 transition-transform duration-500 ease-out group-hover:translate-y-0">
          <span className="inline-flex items-center rounded-full bg-white/15 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/90 backdrop-blur-md ring-1 ring-white/25">
            {tile.category}
          </span>
          <h3 className="mt-1.5 text-sm font-semibold text-white drop-shadow-sm sm:text-base">
            {tile.title}
          </h3>
        </div>
        <span
          aria-hidden="true"
          className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/15 text-white opacity-0 backdrop-blur-md ring-1 ring-white/25 transition-all duration-500 group-hover:opacity-100 group-hover:scale-100 scale-90"
        >
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M7 17 17 7M9 7h8v8" />
          </svg>
        </span>
      </div>
    </motion.button>
  );
}

function Lightbox({
  tiles,
  index,
  onClose,
  onNav,
}: {
  tiles: Tile[];
  index: number;
  onClose: () => void;
  onNav: (dir: 1 | -1) => void;
}) {
  const tile = tiles[index];
  const closeRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    closeRef.current?.focus();
  }, []);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
      if (e.key === "ArrowRight") onNav(1);
      if (e.key === "ArrowLeft") onNav(-1);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, onNav]);

  return (
    <motion.div
      role="dialog"
      aria-modal="true"
      aria-label={`${tile.title}, ${tile.category}`}
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      transition={{ duration: 0.25 }}
      onClick={onClose}
      className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 p-4 backdrop-blur-md sm:p-8"
    >
      <button
        ref={closeRef}
        type="button"
        onClick={onClose}
        aria-label="Close gallery viewer"
        className="absolute right-4 top-4 grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none ring-1 ring-white/20 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:right-6 sm:top-6"
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M18 6 6 18M6 6l12 12" />
        </svg>
      </button>

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

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

      <AnimatePresence mode="wait">
        <motion.figure
          key={tile.id}
          initial={{ opacity: 0, scale: 0.94, y: 12 }}
          animate={{ opacity: 1, scale: 1, y: 0 }}
          exit={{ opacity: 0, scale: 0.97, y: -12 }}
          transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
          onClick={(e) => e.stopPropagation()}
          className="flex max-h-[85vh] w-full max-w-4xl flex-col items-center"
        >
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={tile.src}
            alt={tile.alt}
            loading="lazy"
            draggable={false}
            className="max-h-[72vh] w-auto max-w-full rounded-2xl object-contain shadow-2xl ring-1 ring-white/10"
          />
          <figcaption className="mt-4 flex items-center gap-3 text-center">
            <span className="inline-flex items-center rounded-full bg-white/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/80 ring-1 ring-white/20">
              {tile.category}
            </span>
            <span className="text-sm font-medium text-white/90">{tile.title}</span>
            <span className="text-xs tabular-nums text-white/50">
              {index + 1} / {tiles.length}
            </span>
          </figcaption>
        </motion.figure>
      </AnimatePresence>
    </motion.div>
  );
}

export default function GalleryBento() {
  const [active, setActive] = useState<number | null>(null);

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

  useEffect(() => {
    if (active === null) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = prev;
    };
  }, [active]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes gbento-float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -14px, 0); }
        }
        .gbento-orb { animation: gbento-float 12s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gbento-orb { animation: none; }
        }
      `}</style>

      {/* ambient background */}
      <div
        aria-hidden="true"
        className="gbento-orb pointer-events-none absolute -top-32 left-1/4 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden="true"
        className="gbento-orb pointer-events-none absolute -bottom-24 right-1/4 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/10"
        style={{ animationDelay: "-6s" }}
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="mb-10 flex flex-col items-start justify-between gap-4 sm:mb-14 sm:flex-row sm:items-end">
          <div>
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Selected Work
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
              The Bento Collection
            </h2>
            <p className="mt-3 max-w-md text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
              A curated grid of light, landscape and studio frames. Tap any tile
              to open the full-frame viewer.
            </p>
          </div>
          <p className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
            {TILES.length} frames
          </p>
        </header>

        <div className="grid auto-rows-[160px] grid-cols-2 gap-3 sm:auto-rows-[190px] sm:grid-cols-4 sm:gap-4 lg:auto-rows-[210px]">
          {TILES.map((tile, i) => (
            <Tile key={tile.id} tile={tile} index={i} onOpen={open} />
          ))}
        </div>
      </div>

      <AnimatePresence>
        {active !== null && (
          <Lightbox tiles={TILES} index={active} onClose={close} onNav={nav} />
        )}
      </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.