Web InnoventixFreeCode

Tab Underline Gallery

Original · free

A tabbed gallery with a sliding underline that switches between image sets.

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

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

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

type Photo = {
  src: string;
  title: string;
  location: string;
  alt: string;
  ratio: Ratio;
};

type Collection = {
  id: string;
  label: string;
  meta: string;
  blurb: string;
  photos: Photo[];
};

const COLLECTIONS: Collection[] = [
  {
    id: "architecture",
    label: "Architecture",
    meta: "12 frames",
    blurb: "Concrete, glass and the geometry of built space.",
    photos: [
      {
        src: "/img/gallery/g01.webp",
        title: "Brutalist Stairwell",
        location: "Barbican Estate, London",
        alt: "Curved concrete stairwell inside the Barbican Estate lit by cool daylight",
        ratio: "landscape",
      },
      {
        src: "/img/gallery/g02.webp",
        title: "Glass Atrium",
        location: "Fondation Louis Vuitton, Paris",
        alt: "Sail-like glass panels of a museum atrium seen from below",
        ratio: "portrait",
      },
      {
        src: "/img/gallery/g03.webp",
        title: "Concrete Vault",
        location: "Salk Institute, La Jolla",
        alt: "Symmetrical concrete courtyard opening toward the Pacific horizon",
        ratio: "square",
      },
      {
        src: "/img/gallery/g04.webp",
        title: "Spiral Ascent",
        location: "Vatican Museums, Rome",
        alt: "Double-helix marble staircase spiralling upward under a skylight",
        ratio: "portrait",
      },
    ],
  },
  {
    id: "portraits",
    label: "Portraits",
    meta: "9 frames",
    blurb: "People, close and unguarded, shot on film.",
    photos: [
      {
        src: "/img/gallery/g05.webp",
        title: "Studio Light No. 4",
        location: "Shot on Kodak Portra 400",
        alt: "Studio portrait of a person half in shadow against a warm backdrop",
        ratio: "portrait",
      },
      {
        src: "/img/gallery/g06.webp",
        title: "Quiet Profile",
        location: "Natural window light",
        alt: "Side profile portrait softly lit by a single window",
        ratio: "square",
      },
      {
        src: "/img/gallery/g07.webp",
        title: "Backstage",
        location: "Milan Fashion Week",
        alt: "Candid backstage portrait of a model between runway shows",
        ratio: "portrait",
      },
      {
        src: "/img/gallery/g08.webp",
        title: "Two Dancers",
        location: "Rehearsal, 6am",
        alt: "Two dancers mid-rehearsal in an empty studio at dawn",
        ratio: "landscape",
      },
    ],
  },
  {
    id: "landscapes",
    label: "Landscapes",
    meta: "15 frames",
    blurb: "Wide country, weather, and the long light of morning.",
    photos: [
      {
        src: "/img/gallery/g09.webp",
        title: "Fjord at Dawn",
        location: "Lofoten, Norway",
        alt: "Still fjord water reflecting jagged peaks under a pink dawn sky",
        ratio: "landscape",
      },
      {
        src: "/img/gallery/g10.webp",
        title: "Redwood Canopy",
        location: "Humboldt County, California",
        alt: "Towering redwood trunks vanishing into a green canopy overhead",
        ratio: "portrait",
      },
      {
        src: "/img/gallery/g11.webp",
        title: "Salt Flats",
        location: "Salar de Uyuni, Bolivia",
        alt: "Mirror-flat salt plain reflecting an empty blue sky to the horizon",
        ratio: "square",
      },
      {
        src: "/img/gallery/g12.webp",
        title: "Coastal Fog",
        location: "Big Sur, California",
        alt: "Fog rolling over cliffs where the highway meets the Pacific coast",
        ratio: "landscape",
      },
    ],
  },
];

const RATIO_CLASS: Record<Ratio, string> = {
  portrait: "aspect-[4/5]",
  landscape: "aspect-[16/10]",
  square: "aspect-square",
};

export default function GalleryTabUnderline() {
  const uid = useId().replace(/[:]/g, "");
  const reduce = useReducedMotion();

  const [active, setActive] = useState<number>(0);
  const [lightbox, setLightbox] = useState<number | null>(null);

  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const closeRef = useRef<HTMLButtonElement | null>(null);

  const collection = COLLECTIONS[active];
  const photos = collection.photos;

  const onTabKey = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      const last = COLLECTIONS.length - 1;
      let next = active;
      if (event.key === "ArrowRight") next = active === last ? 0 : active + 1;
      else if (event.key === "ArrowLeft") next = active === 0 ? last : active - 1;
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = last;
      else return;
      event.preventDefault();
      setActive(next);
      tabRefs.current[next]?.focus();
    },
    [active],
  );

  const closeLightbox = useCallback(() => setLightbox(null), []);
  const stepLightbox = useCallback(
    (dir: 1 | -1) => {
      setLightbox((current) => {
        if (current === null) return current;
        const len = photos.length;
        return (current + dir + len) % len;
      });
    },
    [photos.length],
  );

  useEffect(() => {
    if (lightbox === null) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") closeLightbox();
      else if (event.key === "ArrowRight") stepLightbox(1);
      else if (event.key === "ArrowLeft") stepLightbox(-1);
    };
    window.addEventListener("keydown", onKey);
    const raf = requestAnimationFrame(() => closeRef.current?.focus());
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      cancelAnimationFrame(raf);
      document.body.style.overflow = prevOverflow;
    };
  }, [lightbox, closeLightbox, stepLightbox]);

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 520, damping: 40, mass: 0.9 };

  const activePhoto = lightbox !== null ? photos[lightbox] : null;

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-5 py-20 text-neutral-900 sm:px-8 sm:py-28 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes ${uid}-drift {
          0%   { transform: translate3d(0,0,0) scale(1); opacity: .55; }
          50%  { transform: translate3d(3%, -4%, 0) scale(1.08); opacity: .8; }
          100% { transform: translate3d(0,0,0) scale(1); opacity: .55; }
        }
        @keyframes ${uid}-rise {
          from { opacity: 0; transform: translateY(14px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        .${uid}-blob { animation: ${uid}-drift 18s ease-in-out infinite; }
        .${uid}-head { animation: ${uid}-rise .7s cubic-bezier(.2,.7,.2,1) both; }
        @media (prefers-reduced-motion: reduce) {
          .${uid}-blob, .${uid}-head { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div
          className={`${uid}-blob absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20`}
        />
        <div
          className={`${uid}-blob absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-rose-300/30 blur-3xl dark:bg-violet-700/20`}
          style={{ animationDelay: "-9s" }}
        />
        <div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,theme(colors.neutral.400/.14)_1px,transparent_0)] [background-size:22px_22px] dark:bg-[radial-gradient(circle_at_1px_1px,theme(colors.neutral.100/.05)_1px,transparent_0)]" />
      </div>

      <div className="mx-auto max-w-6xl">
        <header className={`${uid}-head mb-10 max-w-2xl`}>
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-300/70 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-neutral-500 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Field Archive
          </span>
          <h2 className="mt-5 text-4xl font-semibold tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50">
            Selected Work,
            <span className="text-neutral-400 dark:text-neutral-500"> by series</span>
          </h2>
          <p className="mt-3 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            A rotating cut from the archive. Switch series to load a different
            set of frames — tap any image to view it full-bleed.
          </p>
        </header>

        {/* Tabs with sliding underline */}
        <div className="relative mb-8 border-b border-neutral-200 dark:border-neutral-800">
          <div
            role="tablist"
            aria-label="Photo series"
            onKeyDown={onTabKey}
            className="flex flex-wrap gap-x-6 gap-y-2 sm:gap-x-9"
          >
            {COLLECTIONS.map((c, i) => {
              const selected = i === active;
              return (
                <button
                  key={c.id}
                  ref={(el) => {
                    tabRefs.current[i] = el;
                  }}
                  role="tab"
                  id={`${uid}-tab-${c.id}`}
                  aria-selected={selected}
                  aria-controls={`${uid}-panel-${c.id}`}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setActive(i)}
                  className={`group relative -mb-px flex items-baseline gap-2 pb-4 pt-1 text-lg font-medium outline-none transition-colors focus-visible:text-indigo-600 dark:focus-visible:text-indigo-400 ${
                    selected
                      ? "text-neutral-900 dark:text-neutral-50"
                      : "text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-200"
                  }`}
                >
                  <span className="rounded-sm px-0.5 focus-visible:outline-none group-focus-visible:ring-2 group-focus-visible:ring-indigo-500/50">
                    {c.label}
                  </span>
                  <span
                    className={`hidden text-[11px] font-normal tabular-nums transition-colors sm:inline ${
                      selected
                        ? "text-neutral-400 dark:text-neutral-500"
                        : "text-neutral-300 dark:text-neutral-600"
                    }`}
                  >
                    {c.meta}
                  </span>
                  {selected && (
                    <motion.span
                      layoutId={`${uid}-underline`}
                      transition={spring}
                      className="absolute inset-x-0 -bottom-px h-[2px] rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-rose-500"
                    >
                      <span className="absolute inset-0 rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-rose-500 blur-[3px] opacity-60" />
                    </motion.span>
                  )}
                </button>
              );
            })}
          </div>
        </div>

        {/* Panel */}
        <div
          role="tabpanel"
          id={`${uid}-panel-${collection.id}`}
          aria-labelledby={`${uid}-tab-${collection.id}`}
          tabIndex={0}
          className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40"
        >
          <p className="mb-6 text-sm text-neutral-500 dark:text-neutral-400">
            {collection.blurb}
          </p>

          <AnimatePresence mode="wait">
            <motion.div
              key={collection.id}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
              transition={{ duration: reduce ? 0 : 0.32, ease: [0.2, 0.7, 0.2, 1] }}
              className="gap-4 [column-fill:_balance] sm:columns-2 lg:columns-3"
              style={{ columnGap: "1rem" }}
            >
              {photos.map((photo, i) => (
                <motion.figure
                  key={photo.src}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduce ? 0 : 0.45,
                    delay: reduce ? 0 : i * 0.06,
                    ease: [0.2, 0.7, 0.2, 1],
                  }}
                  className="mb-4 break-inside-avoid"
                >
                  <button
                    type="button"
                    onClick={() => setLightbox(i)}
                    aria-label={`Open ${photo.title}, ${photo.location}`}
                    className="group relative block w-full overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-1 ring-neutral-900/5 transition-shadow duration-300 hover:shadow-2xl hover:shadow-neutral-900/15 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-800 dark:ring-white/10 dark:hover:shadow-black/40"
                  >
                    <div className={`w-full ${RATIO_CLASS[photo.ratio]}`}>
                      {/* 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-[900ms] ease-[cubic-bezier(.2,.7,.2,1)] group-hover:scale-[1.05]"
                      />
                    </div>
                    <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/70 via-neutral-950/0 to-neutral-950/0 opacity-70 transition-opacity duration-300 group-hover:opacity-90" />
                    <figcaption className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 text-left opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
                      <p className="text-sm font-semibold text-white drop-shadow">
                        {photo.title}
                      </p>
                      <p className="text-xs text-white/70">{photo.location}</p>
                    </figcaption>
                    <span className="pointer-events-none absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/85 text-neutral-800 opacity-0 backdrop-blur transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-100">
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="h-4 w-4"
                        aria-hidden
                      >
                        <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
                      </svg>
                    </span>
                  </button>
                </motion.figure>
              ))}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {activePhoto && (
          <motion.div
            key="lightbox"
            role="dialog"
            aria-modal="true"
            aria-label={`${activePhoto.title}, ${activePhoto.location}`}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.2 }}
            className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/85 p-4 backdrop-blur-md sm:p-8"
            onClick={closeLightbox}
          >
            <motion.figure
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 6 }}
              transition={
                reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 30 }
              }
              onClick={(e) => e.stopPropagation()}
              className="relative flex max-h-full w-full max-w-4xl flex-col"
            >
              <div className="overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl ring-1 ring-white/10">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={activePhoto.src}
                  alt={activePhoto.alt}
                  loading="lazy"
                  draggable={false}
                  className="max-h-[74vh] w-full object-contain"
                />
              </div>
              <figcaption className="mt-4 flex items-end justify-between gap-4">
                <div>
                  <p className="text-base font-semibold text-white">
                    {activePhoto.title}
                  </p>
                  <p className="text-sm text-white/60">{activePhoto.location}</p>
                </div>
                <p className="shrink-0 text-xs tabular-nums text-white/50">
                  {(lightbox ?? 0) + 1} / {photos.length}
                </p>
              </figcaption>

              <button
                ref={closeRef}
                type="button"
                onClick={closeLightbox}
                aria-label="Close image viewer"
                className="absolute -top-2 right-0 flex h-10 w-10 -translate-y-full items-center justify-center rounded-full bg-white/10 text-white outline-none ring-1 ring-white/20 transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:-right-2"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  className="h-5 w-5"
                  aria-hidden
                >
                  <path d="M6 6l12 12M18 6L6 18" />
                </svg>
              </button>

              <button
                type="button"
                onClick={() => stepLightbox(-1)}
                aria-label="Previous image"
                className="absolute left-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-1 ring-white/20 backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:-left-16"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="h-5 w-5"
                  aria-hidden
                >
                  <path d="M15 18l-6-6 6-6" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => stepLightbox(1)}
                aria-label="Next image"
                className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-1 ring-white/20 backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:-right-16"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="h-5 w-5"
                  aria-hidden
                >
                  <path d="M9 18l6-6-6-6" />
                </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.