Web InnoventixFreeCode

Hexagon Gallery

Original · free

Hexagonal image tiles in a honeycomb layout that scale on hover.

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

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

interface HexPhoto {
  src: string;
  alt: string;
  title: string;
  category: string;
  caption: string;
}

const PHOTOS: HexPhoto[] = [
  {
    src: "/img/gallery/g01.webp",
    alt: "Dawn light spilling over a ridge of folded sandstone in the high desert",
    title: "First Light, Coyote Buttes",
    category: "Desert",
    caption: "5:41 a.m. — the sun clears the rim and the whole wall turns to ember.",
  },
  {
    src: "/img/gallery/g02.webp",
    alt: "A lone fisherman casting into a mirror-still alpine lake at blue hour",
    title: "The Patient Hour",
    category: "Water",
    caption: "One cast, one ripple, and forty minutes of absolute stillness.",
  },
  {
    src: "/img/gallery/g03.webp",
    alt: "Terraced rice paddies glowing green under low monsoon cloud",
    title: "Stepped Fields of Bali",
    category: "Landscape",
    caption: "Every terrace hand-cut, flooded by gravity older than the road below.",
  },
  {
    src: "/img/gallery/g04.webp",
    alt: "Close portrait of an artisan weaver with dye-stained hands at a loom",
    title: "Hands That Remember",
    category: "Portrait",
    caption: "Fifty years at the same loom. The pattern lives in her wrists now.",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "Neon-lit rain-slicked alley reflecting signs in a night market",
    title: "Wet Neon, Kowloon",
    category: "Urban",
    caption: "Rain turns the pavement into a second sky of borrowed light.",
  },
  {
    src: "/img/gallery/g06.webp",
    alt: "A single autumn maple leaf frozen mid-fall against dark pine forest",
    title: "The Long Descent",
    category: "Nature",
    caption: "Caught at 1/2000s — a single leaf suspended between two seasons.",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Snow-dusted peaks catching alpenglow above a valley of fog",
    title: "Alpenglow Over the Fog Sea",
    category: "Mountains",
    caption: "The valley disappears; only the summits agree to be seen.",
  },
  {
    src: "/img/gallery/g08.webp",
    alt: "Overhead view of a turquoise coastline meeting pale limestone cliffs",
    title: "Edge of the Adriatic",
    category: "Coast",
    caption: "Shot from a cliff path where the water reads every shade of blue at once.",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Golden dunes rippled by wind with a caravan of tiny figures crossing",
    title: "Crossing the Erg",
    category: "Desert",
    caption: "Scale only makes sense once you find the people in the frame.",
  },
  {
    src: "/img/gallery/g10.webp",
    alt: "Bioluminescent waves breaking on a dark beach under a field of stars",
    title: "Two Kinds of Starlight",
    category: "Night",
    caption: "The tide glowed blue for three nights, then never again that summer.",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Street musician mid-song under warm tungsten light in an old arcade",
    title: "The 11 O'Clock Set",
    category: "Portrait",
    caption: "He plays until the arcade lights cut out. This was the last song.",
  },
];

const CLIP =
  "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)";

const ROWS: number[][] = [
  [0, 1, 2, 3],
  [4, 5, 6],
  [7, 8, 9, 10],
];

const cellVariants: Variants = {
  hidden: { opacity: 0, scale: 0.82, y: 26 },
  show: (i: number) => ({
    opacity: 1,
    scale: 1,
    y: 0,
    transition: {
      delay: i * 0.055,
      duration: 0.55,
      ease: [0.22, 1, 0.36, 1],
    },
  }),
};

export default function GalleryHexagon() {
  const headingId = useId();
  const [active, setActive] = useState<number | null>(null);
  const reduce = useReducedMotion();
  const triggerRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const lastTrigger = useRef<number | null>(null);

  const open = useCallback((index: number) => {
    lastTrigger.current = index;
    setActive(index);
  }, []);

  const close = useCallback(() => {
    setActive(null);
    const idx = lastTrigger.current;
    if (idx != null) triggerRefs.current[idx]?.focus();
  }, []);

  const step = useCallback((dir: 1 | -1) => {
    setActive((prev) => {
      if (prev == null) return prev;
      return (prev + dir + PHOTOS.length) % PHOTOS.length;
    });
  }, []);

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

  let flatIndex = -1;

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-24 sm:px-8 lg:py-32 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
    >
      <style>{`
        @keyframes ghx-drift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -22px, 0) scale(1.08); }
        }
        @keyframes ghx-drift-b {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(18px, 16px, 0) scale(1.12); }
        }
        .ghx-blob { animation: ghx-drift 16s ease-in-out infinite; }
        .ghx-blob-b { animation: ghx-drift-b 21s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ghx-blob, .ghx-blob-b { animation: none !important; }
        }
      `}</style>

      {/* Decorative background */}
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div className="ghx-blob absolute -left-24 top-8 h-80 w-80 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="ghx-blob-b absolute -right-16 bottom-0 h-96 w-96 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
        <div className="absolute left-1/2 top-1/3 h-72 w-72 -translate-x-1/2 rounded-full bg-emerald-200/30 blur-3xl dark:bg-emerald-700/10" />
      </div>

      <div className="mx-auto max-w-5xl">
        <header className="mx-auto mb-16 max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3.5 py-1 text-xs font-semibold uppercase tracking-[0.18em] 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 dark:bg-indigo-400" />
            Honeycomb Series
          </span>
          <h2
            id={headingId}
            className="mt-6 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white"
          >
            Eleven frames, one hive
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-zinc-400">
            A field notebook in glass. Hover any cell to lift it from the comb,
            then open it full-frame. Everything is keyboard-navigable.
          </p>
        </header>

        {/* Honeycomb */}
        <div className="flex flex-col items-center [--hx-gap:0.5rem] [--hx-w:clamp(6.5rem,26vw,11rem)] [--hx-h:calc(var(--hx-w)*1.1547)]">
          {ROWS.map((row, rowIdx) => (
            <div
              key={rowIdx}
              className="flex justify-center gap-[var(--hx-gap)]"
              style={{
                marginTop: rowIdx === 0 ? 0 : "calc(var(--hx-h) * -0.245)",
                marginLeft:
                  row.length < 4
                    ? "calc((var(--hx-w) + var(--hx-gap)) * 0.5)"
                    : undefined,
              }}
            >
              {row.map((photoIdx) => {
                flatIndex += 1;
                const i = flatIndex;
                const photo = PHOTOS[photoIdx];
                return (
                  <motion.div
                    key={photoIdx}
                    custom={i}
                    variants={cellVariants}
                    initial={reduce ? false : "hidden"}
                    whileInView="show"
                    viewport={{ once: true, amount: 0.35 }}
                    className="group relative"
                    style={{
                      width: "var(--hx-w)",
                      height: "var(--hx-h)",
                    }}
                  >
                    <button
                      ref={(el) => {
                        triggerRefs.current[photoIdx] = el;
                      }}
                      type="button"
                      onClick={() => open(photoIdx)}
                      aria-label={`Open ${photo.title} — ${photo.category}`}
                      className="relative block h-full w-full origin-center cursor-pointer transition-[transform,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] outline-none will-change-transform hover:z-20 hover:scale-[1.14] focus-visible:z-20 focus-visible:scale-[1.14]"
                      style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
                    >
                      {/* Grout / frame layer */}
                      <span
                        aria-hidden
                        className="absolute inset-0 bg-white dark:bg-zinc-800"
                        style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
                      />
                      {/* Image, inset by the grout width */}
                      <span
                        className="absolute inset-[3px] overflow-hidden"
                        style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
                      >
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={photo.src}
                          alt={photo.alt}
                          loading="lazy"
                          draggable={false}
                          className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
                        />
                        {/* Tint + label overlay */}
                        <span
                          aria-hidden
                          className="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/5 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-95"
                        />
                        <span className="absolute inset-x-0 bottom-0 flex flex-col items-center px-3 pb-[18%] text-center">
                          <span className="translate-y-2 text-[0.6rem] font-semibold uppercase tracking-[0.16em] text-indigo-200 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
                            {photo.category}
                          </span>
                          <span className="mt-1 line-clamp-2 translate-y-2 text-[0.8rem] font-semibold leading-tight text-white opacity-0 transition-all delay-[60ms] duration-500 group-hover:translate-y-0 group-hover:opacity-100">
                            {photo.title}
                          </span>
                        </span>
                      </span>
                      {/* Ring on hover/focus */}
                      <span
                        aria-hidden
                        className="absolute inset-0 opacity-0 ring-2 ring-inset ring-indigo-400/0 transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100"
                        style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
                      >
                        <span
                          className="absolute inset-0 ring-2 ring-inset ring-indigo-400/80 dark:ring-indigo-300/80"
                          style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
                        />
                      </span>
                    </button>
                  </motion.div>
                );
              })}
            </div>
          ))}
        </div>

        <p className="mt-16 text-center text-sm text-slate-500 dark:text-zinc-500">
          {PHOTOS.length} plates · shot on assignment · arrow keys navigate the
          lightbox
        </p>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {active != null && (
          <motion.div
            key="ghx-lightbox"
            role="dialog"
            aria-modal="true"
            aria-label={`${PHOTOS[active].title}, image ${active + 1} of ${PHOTOS.length}`}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.22 }}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
            onClick={close}
          >
            <motion.figure
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 16 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
              transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
              onClick={(e) => e.stopPropagation()}
              className="relative flex max-h-full w-full max-w-3xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-900 shadow-2xl"
            >
              <div className="relative flex items-center justify-center bg-black">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={PHOTOS[active].src}
                  alt={PHOTOS[active].alt}
                  loading="lazy"
                  draggable={false}
                  className="max-h-[65vh] w-full object-contain"
                />
              </div>
              <figcaption className="flex items-start justify-between gap-4 px-6 py-5">
                <div>
                  <span className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-300">
                    {PHOTOS[active].category}
                  </span>
                  <h3 className="mt-1 text-lg font-semibold text-white">
                    {PHOTOS[active].title}
                  </h3>
                  <p className="mt-1.5 max-w-md text-sm leading-relaxed text-zinc-400">
                    {PHOTOS[active].caption}
                  </p>
                </div>
                <span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tabular-nums text-zinc-300">
                  {active + 1} / {PHOTOS.length}
                </span>
              </figcaption>

              {/* Controls */}
              <button
                type="button"
                onClick={close}
                aria-label="Close"
                className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
                  <path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => step(-1)}
                aria-label="Previous image"
                className="absolute left-3 top-[calc(32.5vh-1.25rem)] grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
                  <path d="M15 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => step(1)}
                aria-label="Next image"
                className="absolute right-3 top-[calc(32.5vh-1.25rem)] grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
                  <path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
            </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.