Expanding Strip Gallery
Original · freeA horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.
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-expanding-strip.json"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
type Panel = {
src: string;
category: string;
title: string;
location: string;
alt: string;
};
const PANELS: Panel[] = [
{
src: "/img/gallery/g03.webp",
category: "Coastal",
title: "Salt & Low Tide",
location: "Cape Perpetua, OR",
alt: "Rocky shoreline washed in pale morning light as the tide draws back",
},
{
src: "/img/gallery/g11.webp",
category: "Studio",
title: "Studio 04",
location: "Analog session",
alt: "Portrait lit with a single soft key against a deep shadowed backdrop",
},
{
src: "/img/gallery/g18.webp",
category: "Nature",
title: "Understory",
location: "Olympic Forest",
alt: "Ferns and moss glowing in a shaft of forest light",
},
{
src: "/img/gallery/g22.webp",
category: "Urban",
title: "Concrete Hours",
location: "District 7",
alt: "Geometric facade of a brutalist building at golden hour",
},
{
src: "/img/gallery/g07.webp",
category: "Coastal",
title: "Blue Hour Pier",
location: "North Jetty",
alt: "A wooden pier stretching into calm water under a fading blue sky",
},
{
src: "/img/gallery/g29.webp",
category: "Still Life",
title: "Table No. 9",
location: "Kitchen window",
alt: "Ceramic vessels and fruit arranged in warm window light",
},
{
src: "/img/gallery/g14.webp",
category: "Nature",
title: "Cloud Ridge",
location: "Cascade Pass",
alt: "Layered mountain ridges disappearing into a bank of soft clouds",
},
];
export default function GalleryExpandingStrip() {
const [active, setActive] = useState(0);
const panelRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
panelRefs.current = panelRefs.current.slice(0, PANELS.length);
}, []);
const focusPanel = (index: number) => {
const clamped = (index + PANELS.length) % PANELS.length;
setActive(clamped);
panelRefs.current[clamped]?.focus();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
if (e.key === "ArrowRight") {
e.preventDefault();
focusPanel(index + 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
focusPanel(index - 1);
} else if (e.key === "Home") {
e.preventDefault();
focusPanel(0);
} else if (e.key === "End") {
e.preventDefault();
focusPanel(PANELS.length - 1);
}
};
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-20 text-neutral-900 sm:px-6 lg:px-10 lg:py-28 dark:bg-neutral-950 dark:text-neutral-50">
<style>{`
@keyframes ges-caption-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes ges-sheen {
from { transform: translateX(-120%) skewX(-12deg); }
to { transform: translateX(260%) skewX(-12deg); }
}
.ges-active .ges-caption { animation: ges-caption-rise 620ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.ges-active .ges-sheen { animation: ges-sheen 1400ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both; }
@media (prefers-reduced-motion: reduce) {
.ges-active .ges-caption,
.ges-active .ges-sheen { animation: none !important; }
.ges-panel { transition: none !important; }
.ges-img { transition: none !important; }
}
`}</style>
<div className="pointer-events-none absolute inset-0 -z-10 opacity-[0.5] [mask-image:radial-gradient(70%_60%_at_50%_0%,black,transparent)]">
<div className="absolute inset-0 bg-[radial-gradient(60%_50%_at_20%_0%,theme(colors.indigo.200),transparent),radial-gradient(50%_50%_at_85%_10%,theme(colors.emerald.200),transparent)] dark:bg-[radial-gradient(60%_50%_at_20%_0%,theme(colors.indigo.900),transparent),radial-gradient(50%_50%_at_85%_10%,theme(colors.emerald.900),transparent)]" />
</div>
<div className="mx-auto max-w-7xl">
<div className="mb-10 flex flex-col gap-5 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<motion.span
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.5 }}
className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-neutral-600 backdrop-blur dark:border-neutral-700 dark:bg-white/5 dark:text-neutral-300"
>
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Selected Frames
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55, delay: 0.05 }}
className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl"
>
A strip of light,
<span className="text-neutral-400 dark:text-neutral-500"> expanded.</span>
</motion.h2>
</div>
<motion.p
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55, delay: 0.1 }}
className="max-w-sm text-sm leading-relaxed text-neutral-600 dark:text-neutral-400"
>
Hover, tap, or arrow through the panels. Each frame opens to reveal
where it was made and the story it holds.
</motion.p>
</div>
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.6 }}
role="group"
aria-label="Expanding image gallery. Use left and right arrow keys to move between frames."
className="flex h-[460px] w-full flex-col gap-2 sm:h-[520px] sm:flex-row lg:h-[560px]"
>
{PANELS.map((panel, i) => {
const isActive = active === i;
return (
<button
key={panel.src}
ref={(el) => {
panelRefs.current[i] = el;
}}
type="button"
aria-label={`${panel.title}, ${panel.category}. ${panel.location}.`}
aria-pressed={isActive}
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
onKeyDown={(e) => onKeyDown(e, i)}
style={{ flexGrow: isActive ? 7 : 1 }}
className={[
"ges-panel group relative min-h-[64px] min-w-0 flex-shrink basis-0 overflow-hidden rounded-2xl outline-none",
"ring-1 ring-black/5 transition-[flex-grow] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] dark:ring-white/10",
"focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950",
isActive ? "ges-active" : "",
].join(" ")}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={panel.src}
alt={panel.alt}
loading="lazy"
draggable={false}
className={[
"ges-img absolute inset-0 h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] will-change-transform",
isActive ? "scale-100" : "scale-[1.18]",
].join(" ")}
/>
{/* readability gradient */}
<div
className={[
"absolute inset-0 bg-gradient-to-t transition-opacity duration-500",
isActive
? "from-black/75 via-black/20 to-black/10 opacity-100"
: "from-black/60 via-black/25 to-black/30 opacity-90",
].join(" ")}
/>
{/* sheen sweep on activate */}
<span className="pointer-events-none absolute inset-y-0 left-0 w-1/2 overflow-hidden">
<span className="ges-sheen absolute inset-y-0 -left-1/2 block w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 [.ges-active_&]:opacity-100" />
</span>
{/* collapsed vertical label */}
<div
className={[
"absolute inset-0 flex items-end justify-center pb-6 transition-opacity duration-300",
isActive ? "pointer-events-none opacity-0" : "opacity-100",
].join(" ")}
>
<span className="[writing-mode:vertical-rl] rotate-180 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/85 drop-shadow">
{panel.category}
</span>
</div>
{/* index badge */}
<span
className={[
"absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold tabular-nums backdrop-blur transition-colors duration-300",
isActive
? "bg-white text-neutral-900"
: "bg-black/30 text-white/90",
].join(" ")}
aria-hidden="true"
>
{String(i + 1).padStart(2, "0")}
</span>
{/* expanded caption */}
<div
className={[
"ges-caption absolute inset-x-0 bottom-0 flex flex-col gap-1 p-5 text-left transition-opacity duration-300 sm:p-6",
isActive ? "opacity-100" : "pointer-events-none opacity-0",
].join(" ")}
>
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-indigo-300">
{panel.category}
</span>
<span className="text-xl font-semibold leading-tight text-white sm:text-2xl">
{panel.title}
</span>
<span className="flex items-center gap-1.5 text-xs text-white/70">
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
{panel.location}
</span>
</div>
</button>
);
})}
</motion.div>
<div className="mt-6 flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-500">
<span aria-live="polite">
{String(active + 1).padStart(2, "0")}
<span className="mx-1 text-neutral-300 dark:text-neutral-700">/</span>
{String(PANELS.length).padStart(2, "0")}
</span>
<span className="hidden items-center gap-2 sm:flex">
<kbd className="rounded border border-neutral-300 bg-white px-1.5 py-0.5 font-sans text-[10px] dark:border-neutral-700 dark:bg-neutral-900">
←
</kbd>
<kbd className="rounded border border-neutral-300 bg-white px-1.5 py-0.5 font-sans text-[10px] dark:border-neutral-700 dark:bg-neutral-900">
→
</kbd>
to explore
</span>
</div>
</div>
</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.

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.

Flip Cards Gallery
OriginalA grid of cards that flip in 3D on hover to reveal a caption and details on the back.

