Sticky Scroll
A scroll-driven storytelling section where your copy scrolls on one side while a sticky visual pane on the other swaps in sync. Pass any JSX — an image, a code block, a stat panel — as each item's visual; the pane crossfades between them as the matching section passes center screen
Features
- ▸Scroll narrates : instead of a static feature list, the story unfolds one beat at a time as the visitor scrolls, pulling them through the whole pitch rather than letting them skim and leave.
- ▸Focus stays clear : the active section stays crisp and bright while the rest gently dims, so visitors never lose their place even while scrolling fast.
- ▸Always in sync : the visual updates the instant its matching copy takes center stage, so there's never a mismatch between what they're reading and what they're seeing.
- ▸Quiet progress cue : small dots beneath the visual act like a chapter tracker, giving a subtle sense of "how much is left" without any clutter or a literal progress bar.
- ▸Native on any device : pinned like a product demo on desktop, gracefully stacked on mobile — never feels broken or repurposed.
Drop-in components, zero config
Every Spark UI component ships as a single file you own. No package lock-in, no theme provider gymnastics — paste it and it works with your Tailwind setup.

Motion that earns its keep
Animation isn't decoration. Well-placed motion guides attention, communicates state, and keeps visitors scrolling — the numbers follow.

Springs, not spinners
Everything runs on transform and opacity with hardware-accelerated springs from Motion. Buttery on a MacBook, still smooth on a mid-range Android.

From idea to shipped in minutes
Browse the gallery, grab the component, wire your content. The sticky pane you're looking at right now is one of them — meta, we know.

01.Install Dependencies
npm install motion clsx tailwind-merge"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValueEvent,
useScroll,
} from "motion/react";
import { cn } from "@/utils/cn";
export function StickyScroll({
items,
className,
paneHeight = "100vh",
scrollContainer,
}) {
const containerRef = React.useRef(null);
const [active, setActive] = React.useState(0);
const { scrollYProgress } = useScroll({
container: scrollContainer,
target: containerRef,
offset: ["start center", "end center"],
});
useMotionValueEvent(scrollYProgress, "change", (v) => {
const idx = Math.min(items.length - 1, Math.max(0, Math.floor(v * items.length)));
setActive(idx);
});
return (
<div
ref={containerRef}
className={cn(
"relative mx-auto grid w-full max-w-6xl grid-cols-1 gap-x-16 px-6 lg:grid-cols-2",
className
)}
>
{/* scrolling copy */}
<div>
{items.map((item, i) => (
<div
key={i}
className="flex flex-col justify-center py-16 lg:min-h-[70vh]"
>
<motion.div
animate={{
opacity: active === i ? 1 : 0.25,
x: active === i ? 0 : -8,
}}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{item.badge && (
<span className="mb-3 inline-block font-mono text-[10px] uppercase tracking-[0.2em] text-white/40">
{item.badge}
</span>
)}
<h3 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">
{item.title}
</h3>
<p className="mt-4 max-w-md text-sm leading-relaxed text-zinc-400">
{item.description}
</p>
</motion.div>
{/* inline visual on mobile, where the sticky pane is hidden */}
<div className="mt-8 lg:hidden">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
{item.content}
</div>
</div>
</div>
))}
</div>
{/* sticky visual pane */}
<div
style={{ height: paneHeight }}
className="sticky top-0 hidden items-center lg:flex"
>
<div className="relative w-full">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02] shadow-[0_20px_80px_rgba(0,0,0,0.5)]">
<AnimatePresence mode="popLayout">
<motion.div
key={active}
initial={{ opacity: 0, y: 32, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -32, scale: 0.96 }}
transition={{ duration: 0.45, ease: [0.32, 0.72, 0, 1] }}
className="absolute inset-0"
>
{items[active].content}
</motion.div>
</AnimatePresence>
</div>
{/* progress rail */}
<div className="mt-5 flex items-center justify-center gap-2">
{items.map((_, i) => (
<motion.span
key={i}
animate={{
width: active === i ? 24 : 8,
opacity: active === i ? 1 : 0.3,
}}
transition={{ duration: 0.3, ease: "easeOut" }}
className="h-1 rounded-full bg-white"
/>
))}
</div>
</div>
</div>
</div>
);
}