Web InnoventixFreeCode

Tabs Collections Gallery

Original · free

A tabbed gallery that switches between named photo collections with an animated grid transition.

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

import { useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

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

interface Photo {
  src: string;
  title: string;
  place: string;
  orientation: Orientation;
}

interface Collection {
  id: string;
  label: string;
  blurb: string;
  /** static Tailwind classes so the component stays portable */
  dot: string;
  ring: string;
  chip: string;
  photos: Photo[];
}

const aspectClass: Record<Orientation, string> = {
  portrait: "aspect-[4/5]",
  landscape: "aspect-[13/9]",
  square: "aspect-square",
};

const collections: Collection[] = [
  {
    id: "coastal",
    label: "Coastal",
    blurb: "Salt air, long horizons and the slow patience of tides.",
    dot: "bg-sky-500",
    ring: "focus-visible:ring-sky-500/60",
    chip: "text-sky-700 dark:text-sky-300",
    photos: [
      { src: "/img/gallery/g02.webp", title: "Low tide, first light", place: "Coastal light", orientation: "landscape" },
      { src: "/img/gallery/g07.webp", title: "Harbour wall", place: "Northern shore", orientation: "portrait" },
      { src: "/img/gallery/g11.webp", title: "Drift", place: "Open water", orientation: "square" },
      { src: "/img/gallery/g20.webp", title: "The long pier", place: "Coastal light", orientation: "landscape" },
      { src: "/img/gallery/g26.webp", title: "Weathered hull", place: "Fishing village", orientation: "portrait" },
      { src: "/img/gallery/g33.webp", title: "Foam & stone", place: "Tidal pool", orientation: "square" },
    ],
  },
  {
    id: "studio",
    label: "Studio",
    blurb: "Controlled light, quiet objects and deliberate composition.",
    dot: "bg-rose-500",
    ring: "focus-visible:ring-rose-500/60",
    chip: "text-rose-700 dark:text-rose-300",
    photos: [
      { src: "/img/gallery/g05.webp", title: "Studio 04", place: "Still life", orientation: "portrait" },
      { src: "/img/gallery/g16.webp", title: "Paper & shadow", place: "Studio 07", orientation: "landscape" },
      { src: "/img/gallery/g23.webp", title: "Ceramic study", place: "Studio 02", orientation: "square" },
      { src: "/img/gallery/g30.webp", title: "Portrait, softbox", place: "Studio 11", orientation: "portrait" },
      { src: "/img/gallery/g09.webp", title: "Fold", place: "Textile series", orientation: "landscape" },
      { src: "/img/gallery/g18.webp", title: "Single bloom", place: "Studio 03", orientation: "square" },
    ],
  },
  {
    id: "nature",
    label: "Nature",
    blurb: "Field, forest and the fine grain of growing things.",
    dot: "bg-emerald-500",
    ring: "focus-visible:ring-emerald-500/60",
    chip: "text-emerald-700 dark:text-emerald-300",
    photos: [
      { src: "/img/gallery/g14.webp", title: "Understory", place: "Nature", orientation: "landscape" },
      { src: "/img/gallery/g21.webp", title: "Frost on fern", place: "Highland edge", orientation: "portrait" },
      { src: "/img/gallery/g27.webp", title: "Meadow, late June", place: "Nature", orientation: "square" },
      { src: "/img/gallery/g36.webp", title: "Canopy break", place: "Old woodland", orientation: "landscape" },
      { src: "/img/gallery/g12.webp", title: "Seed head", place: "Wild margin", orientation: "portrait" },
      { src: "/img/gallery/g29.webp", title: "River bend", place: "Valley floor", orientation: "square" },
    ],
  },
  {
    id: "urban",
    label: "Urban",
    blurb: "Concrete geometry, glass reflections and city rhythm.",
    dot: "bg-amber-500",
    ring: "focus-visible:ring-amber-500/60",
    chip: "text-amber-700 dark:text-amber-300",
    photos: [
      { src: "/img/gallery/g03.webp", title: "Stairwell", place: "Downtown", orientation: "portrait" },
      { src: "/img/gallery/g08.webp", title: "Crossing", place: "Urban", orientation: "landscape" },
      { src: "/img/gallery/g15.webp", title: "Facade, noon", place: "Financial quarter", orientation: "square" },
      { src: "/img/gallery/g24.webp", title: "Underpass", place: "Urban", orientation: "landscape" },
      { src: "/img/gallery/g31.webp", title: "Reflected tower", place: "Riverside", orientation: "portrait" },
      { src: "/img/gallery/g19.webp", title: "Neon rain", place: "Old market", orientation: "square" },
    ],
  },
];

export default function GalleryTabsCollections() {
  const [active, setActive] = useState<number>(0);
  const reduce = useReducedMotion();
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const current = collections[active];

  const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {
    const count = collections.length;
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % count;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + count) % count;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = count - 1;
    else return;
    e.preventDefault();
    setActive(next);
    tabRefs.current[next]?.focus();
  };

  const container = {
    hidden: { opacity: reduce ? 1 : 0 },
    show: {
      opacity: 1,
      transition: reduce ? { duration: 0 } : { staggerChildren: 0.055, delayChildren: 0.04 },
    },
    exit: {
      opacity: reduce ? 1 : 0,
      transition: reduce ? { duration: 0 } : { duration: 0.18 },
    },
  } as const;

  const item = {
    hidden: reduce ? { opacity: 1 } : { opacity: 0, y: 26, filter: "blur(8px)" },
    show: {
      opacity: 1,
      y: 0,
      filter: "blur(0px)",
      transition: reduce ? { duration: 0 } : { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const },
    },
  } as const;

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 py-20 text-neutral-900 sm:py-28 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes gtc-pulse {
          0%, 100% { transform: scale(1); opacity: 1; }
          50% { transform: scale(1.55); opacity: .35; }
        }
        .gtc-pulse { animation: gtc-pulse 2.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gtc-pulse { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.sky.200/45),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.rose.200/35),transparent)] dark:opacity-40 dark:[background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.sky.500/18),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.rose.500/14),transparent)]"
      />

      <div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
        {/* header */}
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.28em] text-neutral-500 dark:text-neutral-400">
            <span className="h-px w-8 bg-neutral-400 dark:bg-neutral-600" />
            Selected work
          </span>
          <h2 className="mt-5 font-serif text-4xl font-medium leading-[1.05] tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50">
            Collections
          </h2>
          <p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            A curated archive, grouped by mood and place. Switch between sets to see
            each collection settle into its own grid.
          </p>
        </div>

        {/* tabs */}
        <div
          role="tablist"
          aria-label="Photo collections"
          className="mt-10 flex flex-wrap gap-1.5 rounded-2xl border border-neutral-200/70 bg-white/60 p-1.5 shadow-sm backdrop-blur-sm sm:inline-flex dark:border-white/10 dark:bg-white/5"
        >
          {collections.map((c, i) => {
            const selected = i === active;
            return (
              <button
                key={c.id}
                ref={(el) => {
                  tabRefs.current[i] = el;
                }}
                role="tab"
                id={`gtc-tab-${c.id}`}
                aria-selected={selected}
                aria-controls="gtc-panel"
                tabIndex={selected ? 0 : -1}
                onClick={() => setActive(i)}
                onKeyDown={(e) => onTabKeyDown(e, i)}
                className={`relative isolate flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 ${c.ring} ${
                  selected
                    ? "text-neutral-900 dark:text-white"
                    : "text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100"
                }`}
              >
                {selected && (
                  <motion.span
                    layoutId="gtc-pill"
                    transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
                    className="absolute inset-0 -z-10 rounded-xl bg-white shadow-sm ring-1 ring-neutral-200/80 dark:bg-neutral-800 dark:ring-white/10"
                  />
                )}
                <span className="relative flex h-1.5 w-1.5 items-center justify-center">
                  <span className={`absolute inline-flex h-1.5 w-1.5 rounded-full ${c.dot} ${selected ? "gtc-pulse" : "opacity-50"}`} />
                  <span className={`inline-flex h-1.5 w-1.5 rounded-full ${c.dot}`} />
                </span>
                {c.label}
                <span className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500">{c.photos.length}</span>
              </button>
            );
          })}
        </div>

        {/* active blurb */}
        <AnimatePresence mode="wait">
          <motion.p
            key={current.id + "-blurb"}
            initial={reduce ? false : { opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? undefined : { opacity: 0, y: -6 }}
            transition={{ duration: 0.25 }}
            className="mt-6 max-w-xl text-sm italic leading-relaxed text-neutral-500 dark:text-neutral-400"
          >
            {current.blurb}
          </motion.p>
        </AnimatePresence>

        {/* panel */}
        <div id="gtc-panel" role="tabpanel" aria-labelledby={`gtc-tab-${current.id}`} tabIndex={0} className="mt-8 outline-none">
          <AnimatePresence mode="wait">
            <motion.div
              key={current.id}
              variants={container}
              initial="hidden"
              animate="show"
              exit="exit"
              className="gap-4 [column-fill:_balance] sm:columns-2 lg:columns-3"
            >
              {current.photos.map((p) => (
                <motion.figure
                  key={p.src}
                  variants={item}
                  className="group relative mb-4 block break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10"
                >
                  <div className={`${aspectClass[p.orientation]} w-full overflow-hidden`}>
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={p.src}
                      alt={`${p.title} — ${p.place}`}
                      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.05]"
                    />
                  </div>

                  {/* gradient + caption */}
                  <div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 pt-12 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
                    <figcaption className="text-sm font-medium leading-tight text-white drop-shadow-sm">
                      {p.title}
                    </figcaption>
                    <span className={`mt-1 inline-block rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${current.chip}`}>
                      {p.place}
                    </span>
                  </div>
                </motion.figure>
              ))}
            </motion.div>
          </AnimatePresence>
        </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.