Tab Underline Gallery
Original · freeA tabbed gallery with a sliding underline that switches between image sets.
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"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
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 quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery
OriginalA Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery
OriginalA filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery
OriginalA grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery
OriginalA thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery
OriginalA horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery
OriginalTwo rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery
OriginalAn image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

