Horizontal Scroll Gallery
Original · freeVertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.
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/scroll-horizontal-gallery.json"use client";
import { useRef, useState } from "react";
import type { ReactNode } from "react";
import { motion, useScroll, useTransform, useSpring, useMotionValueEvent } from "motion/react";
type GalleryCard = {
index: string;
kicker: string;
title: string;
blurb: string;
meta: string;
gradient: string;
darkGradient: string;
accent: string;
art: ReactNode;
};
function ArtOrbit(): ReactNode {
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
<defs>
<radialGradient id="szh-orbit-g" cx="50%" cy="40%" r="70%">
<stop offset="0%" stopColor="rgba(255,255,255,0.9)" />
<stop offset="100%" stopColor="rgba(255,255,255,0)" />
</radialGradient>
</defs>
<circle cx="100" cy="90" r="70" fill="none" stroke="rgba(255,255,255,0.35)" strokeWidth="1" />
<circle cx="100" cy="90" r="48" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1" />
<circle cx="100" cy="90" r="26" fill="url(#szh-orbit-g)" />
<circle cx="100" cy="20" r="5" fill="#fff" />
<circle cx="170" cy="90" r="4" fill="rgba(255,255,255,0.8)" />
<circle cx="52" cy="90" r="3.5" fill="rgba(255,255,255,0.7)" />
</svg>
);
}
function ArtBars(): ReactNode {
const bars = [30, 62, 44, 88, 54, 72, 40, 96, 60];
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
{bars.map((h, i) => (
<rect
key={i}
x={16 + i * 20}
y={170 - h}
width="10"
height={h}
rx="5"
fill="rgba(255,255,255,0.85)"
opacity={0.4 + (i % 3) * 0.2}
/>
))}
</svg>
);
}
function ArtMesh(): ReactNode {
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
{Array.from({ length: 7 }).map((_, r) => (
<path
key={r}
d={`M0 ${20 + r * 26} Q 50 ${4 + r * 26} 100 ${20 + r * 26} T 200 ${20 + r * 26}`}
fill="none"
stroke="rgba(255,255,255,0.5)"
strokeWidth="1"
/>
))}
</svg>
);
}
function ArtGrid(): ReactNode {
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
{Array.from({ length: 6 }).map((_, y) =>
Array.from({ length: 6 }).map((_, x) => (
<circle
key={`${x}-${y}`}
cx={24 + x * 30}
cy={24 + y * 30}
r={2 + ((x + y) % 3)}
fill="rgba(255,255,255,0.8)"
opacity={0.35 + ((x * y) % 5) * 0.12}
/>
))
)}
</svg>
);
}
function ArtWave(): ReactNode {
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
<path
d="M0 120 C 40 60 80 180 120 110 S 180 60 200 120 L 200 200 L 0 200 Z"
fill="rgba(255,255,255,0.28)"
/>
<path
d="M0 140 C 50 90 90 190 140 130 S 190 100 200 150"
fill="none"
stroke="rgba(255,255,255,0.8)"
strokeWidth="2"
/>
</svg>
);
}
function ArtType(): ReactNode {
return (
<svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
<text
x="100"
y="118"
textAnchor="middle"
fontSize="120"
fontWeight="800"
fill="rgba(255,255,255,0.9)"
fontFamily="ui-serif, Georgia, serif"
>
Aa
</text>
<line x1="30" y1="150" x2="170" y2="150" stroke="rgba(255,255,255,0.5)" strokeWidth="1" />
</svg>
);
}
const CARDS: GalleryCard[] = [
{
index: "01",
kicker: "Brand System",
title: "Helios Identity",
blurb: "A living identity for a solar-energy startup, built around orbiting light and warm gradients.",
meta: "Branding · 2026",
gradient: "from-amber-400 via-orange-500 to-rose-500",
darkGradient: "dark:from-amber-500 dark:via-orange-600 dark:to-rose-600",
accent: "text-amber-200",
art: <ArtOrbit />,
},
{
index: "02",
kicker: "Data Product",
title: "Cadence Analytics",
blurb: "Real-time revenue dashboards that make quarterly numbers feel like a heartbeat, not a spreadsheet.",
meta: "Product · 2026",
gradient: "from-sky-400 via-indigo-500 to-violet-600",
darkGradient: "dark:from-sky-500 dark:via-indigo-600 dark:to-violet-700",
accent: "text-sky-200",
art: <ArtBars />,
},
{
index: "03",
kicker: "Motion",
title: "Meridian Mesh",
blurb: "Generative gradient meshes rendered on the GPU for a fintech landing page that never sits still.",
meta: "WebGL · 2026",
gradient: "from-emerald-400 via-teal-500 to-cyan-600",
darkGradient: "dark:from-emerald-500 dark:via-teal-600 dark:to-cyan-700",
accent: "text-emerald-200",
art: <ArtMesh />,
},
{
index: "04",
kicker: "Interface Kit",
title: "Atlas Grid",
blurb: "A 240-component design system with pixel-tuned spacing, shipped as tokens across three platforms.",
meta: "Design System · 2026",
gradient: "from-fuchsia-400 via-purple-500 to-indigo-600",
darkGradient: "dark:from-fuchsia-500 dark:via-purple-600 dark:to-indigo-700",
accent: "text-fuchsia-200",
art: <ArtGrid />,
},
{
index: "05",
kicker: "Campaign",
title: "Tidewave",
blurb: "An interactive scroll story for an ocean-conservation nonprofit, animated on a single canvas layer.",
meta: "Editorial · 2026",
gradient: "from-cyan-400 via-blue-500 to-indigo-600",
darkGradient: "dark:from-cyan-500 dark:via-blue-600 dark:to-indigo-700",
accent: "text-cyan-200",
art: <ArtWave />,
},
{
index: "06",
kicker: "Typography",
title: "Serif Revival",
blurb: "A variable display serif with 14 optical sizes, drawn for a magazine's next hundred covers.",
meta: "Type · 2026",
gradient: "from-rose-400 via-pink-500 to-fuchsia-600",
darkGradient: "dark:from-rose-500 dark:via-pink-600 dark:to-fuchsia-700",
accent: "text-rose-200",
art: <ArtType />,
},
];
export default function ScrollHorizontalGallery() {
const trackRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<number>(0);
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start start", "end end"],
});
const smooth = useSpring(scrollYProgress, {
stiffness: 90,
damping: 24,
restDelta: 0.001,
});
const x = useTransform(smooth, [0, 1], ["0%", "-66%"]);
const progressWidth = useTransform(smooth, [0, 1], ["0%", "100%"]);
const railRotate = useTransform(smooth, [0, 1], [0, -6]);
useMotionValueEvent(smooth, "change", (v: number) => {
const idx = Math.min(CARDS.length - 1, Math.max(0, Math.round(v * (CARDS.length - 1))));
setActive(idx);
});
return (
<section className="relative w-full overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<style>{`
@keyframes szh-drift {
0%, 100% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(0,-18px,0) scale(1.06); }
}
@keyframes szh-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.szh-drift { animation: szh-drift 11s ease-in-out infinite; }
.szh-shimmer {
background-size: 200% auto;
animation: szh-shimmer 6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.szh-drift, .szh-shimmer { animation: none !important; }
}
`}</style>
{/* atmospheric background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
<div className="szh-drift absolute -left-32 top-1/4 h-96 w-96 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="szh-drift absolute -right-24 top-1/3 h-[28rem] w-[28rem] rounded-full bg-fuchsia-300/30 blur-3xl dark:bg-fuchsia-700/20" />
<div
className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
style={{
backgroundImage:
"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
backgroundSize: "56px 56px",
}}
/>
</div>
{/* tall wrapper drives the horizontal pan */}
<div ref={trackRef} className="relative h-[320vh]">
{/* sticky stage */}
<div className="sticky top-0 flex h-screen flex-col overflow-hidden">
{/* header */}
<header className="mx-auto w-full max-w-7xl px-6 pt-12 sm:px-10 lg:pt-16">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Selected Work
</span>
<h2 className="mt-5 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl lg:text-6xl">
A studio in{" "}
<span className="szh-shimmer bg-gradient-to-r from-indigo-500 via-fuchsia-500 to-amber-500 bg-clip-text text-transparent">
motion
</span>
</h2>
<p className="mt-4 max-w-md text-base text-slate-600 dark:text-slate-400">
Scroll to pan the rail. Six projects, one continuous horizon of brand, product and type.
</p>
</div>
<div className="flex items-center gap-4 sm:flex-col sm:items-end">
<div className="text-right">
<div className="font-mono text-5xl font-bold tabular-nums text-slate-900 dark:text-white">
{CARDS[active].index}
</div>
<div className="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400">
/ 06
</div>
</div>
</div>
</div>
</header>
{/* rail */}
<div className="flex flex-1 items-center">
<motion.div
style={{ x, rotate: railRotate }}
className="flex origin-left items-stretch gap-6 pl-6 pr-[10vw] sm:gap-8 sm:pl-10"
>
{CARDS.map((card, i) => (
<article
key={card.index}
className="group relative flex h-[58vh] w-[78vw] shrink-0 flex-col justify-between overflow-hidden rounded-3xl border border-white/40 p-7 shadow-2xl shadow-slate-900/10 ring-1 ring-black/5 sm:w-[62vw] md:w-[46vw] lg:w-[38vw] dark:border-white/10 dark:shadow-black/40"
>
{/* card gradient surface */}
<div
aria-hidden="true"
className={`absolute inset-0 -z-10 bg-gradient-to-br ${card.gradient} ${card.darkGradient}`}
/>
<div
aria-hidden="true"
className="absolute inset-0 -z-10 opacity-30 mix-blend-overlay"
style={{
backgroundImage:
"radial-gradient(circle at 30% 20%, rgba(255,255,255,0.8), transparent 45%)",
}}
/>
{/* top row */}
<div className="flex items-start justify-between">
<span className={`text-xs font-semibold uppercase tracking-[0.2em] ${card.accent}`}>
{card.kicker}
</span>
<span className="font-mono text-sm font-bold text-white/80">{card.index}</span>
</div>
{/* art */}
<div className="pointer-events-none absolute inset-x-0 top-1/2 mx-auto h-40 w-40 -translate-y-1/2 opacity-90 sm:h-48 sm:w-48">
{card.art}
</div>
{/* bottom text */}
<div className="relative">
<h3 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{card.title}
</h3>
<p className="mt-2 max-w-xs text-sm leading-relaxed text-white/85">
{card.blurb}
</p>
<div className="mt-5 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-widest text-white/70">
{card.meta}
</span>
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/20 text-white ring-1 ring-white/40 backdrop-blur transition-transform duration-300 group-hover:translate-x-1">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</article>
))}
</motion.div>
</div>
{/* footer progress */}
<div className="mx-auto w-full max-w-7xl px-6 pb-10 sm:px-10">
<div className="flex items-center gap-4">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">01</span>
<div className="relative h-1 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<motion.div
style={{ width: progressWidth }}
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-indigo-500 via-fuchsia-500 to-amber-500"
/>
</div>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">06</span>
<span className="hidden items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-slate-500 sm:inline-flex dark:text-slate-400">
Scroll
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 5v14M6 13l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</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 →
Scroll Progress Reveal
OriginalA section that reveals its content in sequence as you scroll, with a sticky scroll-progress bar showing how far through it you are.

Parallax Layers
OriginalA layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

Pinned Scroll Steps
OriginalA pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.

Scroll Reveal Timeline
OriginalA vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

Count-up Stats Band
OriginalA metrics band whose numbers count up from zero the moment it scrolls into view.

Clip-path Image Reveal
OriginalPanels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

Stacking Cards
OriginalA deck of cards that pin and scale as you scroll, each settling behind the next.

Scroll Text Highlight
OriginalA statement whose words light up one by one as you scroll through the section.

Scroll Zoom Hero
OriginalA hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

