Horizontal Scroll
Vertical scrolling drives a row of cards sideways in a pinned section.
Scroll down — cards move sideways ↓
01
Discover
Research what users need
02
Define
Frame the right problem
03
Design
Explore many directions
04
Build
Ship real, working code
05
Measure
Learn from every release
06
Repeat
Small loops, big outcomes
End of section
components/ui/horizontal-scroll.tsx
"use client";
import { motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
type HorizontalScrollProps = {
children: ReactNode;
/** Total scroll distance of the section, e.g. "300vh" or 1400 */
height?: string | number;
/** Height of the pinned viewport */
stickyClassName?: string;
container?: RefObject<HTMLElement | null>;
};
export function HorizontalScroll({ children, height = "300vh", stickyClassName = "h-screen", container }: HorizontalScrollProps) {
const section = useRef<HTMLElement>(null);
const viewport = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
const distance = useRef(0);
const { scrollYProgress } = useScroll({ target: section, container, offset: ["start start", "end end"] });
const x = useTransform(scrollYProgress, (v) => -v * distance.current);
useEffect(() => {
const measure = () => {
if (track.current && viewport.current) {
distance.current = Math.max(0, track.current.scrollWidth - viewport.current.clientWidth);
}
};
measure();
const ro = new ResizeObserver(measure);
if (track.current) ro.observe(track.current);
if (viewport.current) ro.observe(viewport.current);
return () => ro.disconnect();
}, []);
return (
<section ref={section} style={{ height }} className="relative">
<div ref={viewport} className={`sticky top-0 flex items-center overflow-hidden ${stickyClassName}`}>
<motion.div ref={track} style={{ x }} className="flex w-max gap-5 px-8">
{children}
</motion.div>
</div>
</section>
);
}
const slides = [
{ title: "Discover", text: "Research what users need", color: "from-sky-400 to-blue-600" },
{ title: "Define", text: "Frame the right problem", color: "from-violet-400 to-indigo-600" },
{ title: "Design", text: "Explore many directions", color: "from-pink-400 to-rose-600" },
{ title: "Build", text: "Ship real, working code", color: "from-amber-400 to-orange-600" },
{ title: "Measure", text: "Learn from every release", color: "from-emerald-400 to-teal-600" },
{ title: "Repeat", text: "Small loops, big outcomes", color: "from-zinc-600 to-zinc-900" },
];
export default function Demo() {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} className="h-[400px] w-full max-w-4xl overflow-y-auto rounded-2xl border border-zinc-200 bg-white">
<div className="flex h-24 items-center justify-center text-sm text-zinc-400">Scroll down — cards move sideways ↓</div>
<HorizontalScroll container={ref} height={1500} stickyClassName="h-[400px]">
{slides.map((s, i) => (
<div key={s.title} className={`flex h-64 w-72 shrink-0 flex-col justify-between rounded-3xl bg-gradient-to-br p-7 text-white shadow-xl ${s.color}`}>
<span className="text-sm font-medium opacity-80">0{i + 1}</span>
<div>
<h3 className="text-3xl font-semibold tracking-tight">{s.title}</h3>
<p className="mt-1 text-white/80">{s.text}</p>
</div>
</div>
))}
</HorizontalScroll>
<div className="flex h-40 items-center justify-center text-sm text-zinc-400">End of section</div>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/horizontal-scroll.tsxcomponents/ui/horizontal-scroll.tsx"use client"; import { motion, useScroll, useTransform } from "motion/react"; import { useEffect, useRef, type ReactNode, type RefObject } from "react"; type HorizontalScrollProps = { children: ReactNode; /** Total scroll distance of the section, e.g. "300vh" or 1400 */ height?: string | number; /** Height of the pinned viewport */ stickyClassName?: string; container?: RefObject<HTMLElement | null>; }; export function HorizontalScroll({ children, height = "300vh", stickyClassName = "h-screen", container }: HorizontalScrollProps) { const section = useRef<HTMLElement>(null); const viewport = useRef<HTMLDivElement>(null); const track = useRef<HTMLDivElement>(null); const distance = useRef(0); const { scrollYProgress } = useScroll({ target: section, container, offset: ["start start", "end end"] }); const x = useTransform(scrollYProgress, (v) => -v * distance.current); useEffect(() => { const measure = () => { if (track.current && viewport.current) { distance.current = Math.max(0, track.current.scrollWidth - viewport.current.clientWidth); } }; measure(); const ro = new ResizeObserver(measure); if (track.current) ro.observe(track.current); if (viewport.current) ro.observe(viewport.current); return () => ro.disconnect(); }, []); return ( <section ref={section} style={{ height }} className="relative"> <div ref={viewport} className={`sticky top-0 flex items-center overflow-hidden ${stickyClassName}`}> <motion.div ref={track} style={{ x }} className="flex w-max gap-5 px-8"> {children} </motion.div> </div> </section> ); } const slides = [ { title: "Discover", text: "Research what users need", color: "from-sky-400 to-blue-600" }, { title: "Define", text: "Frame the right problem", color: "from-violet-400 to-indigo-600" }, { title: "Design", text: "Explore many directions", color: "from-pink-400 to-rose-600" }, { title: "Build", text: "Ship real, working code", color: "from-amber-400 to-orange-600" }, { title: "Measure", text: "Learn from every release", color: "from-emerald-400 to-teal-600" }, { title: "Repeat", text: "Small loops, big outcomes", color: "from-zinc-600 to-zinc-900" }, ]; export default function Demo() { const ref = useRef<HTMLDivElement>(null); return ( <div ref={ref} className="h-[400px] w-full max-w-4xl overflow-y-auto rounded-2xl border border-zinc-200 bg-white"> <div className="flex h-24 items-center justify-center text-sm text-zinc-400">Scroll down — cards move sideways ↓</div> <HorizontalScroll container={ref} height={1500} stickyClassName="h-[400px]"> {slides.map((s, i) => ( <div key={s.title} className={`flex h-64 w-72 shrink-0 flex-col justify-between rounded-3xl bg-gradient-to-br p-7 text-white shadow-xl ${s.color}`}> <span className="text-sm font-medium opacity-80">0{i + 1}</span> <div> <h3 className="text-3xl font-semibold tracking-tight">{s.title}</h3> <p className="mt-1 text-white/80">{s.text}</p> </div> </div> ))} </HorizontalScroll> <div className="flex h-40 items-center justify-center text-sm text-zinc-400">End of section</div> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.