Sticky Scroll
Steps scroll past while a pinned visual swaps to match the active one.
01
Write the idea
Start from a prompt, a sketch or an existing page. The editor understands your intent and drafts the first version.
02
Branch and review
Every change gets its own preview link. Teammates comment in context, right on the page.
03
Check it automatically
Accessibility, performance and broken links are checked on every push — before customers ever see them.
04
Ship worldwide
Merge to main and your site is live on a global edge network in seconds, with instant rollbacks.
components/ui/sticky-scroll.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { GitBranch, Rocket, ShieldCheck, Sparkles, type LucideIcon } from "lucide-react";
import { useRef, useState, type RefObject } from "react";
type StickyItem = { title: string; text: string; icon: LucideIcon; color: string };
const defaultItems: StickyItem[] = [
{ title: "Write the idea", text: "Start from a prompt, a sketch or an existing page. The editor understands your intent and drafts the first version.", icon: Sparkles, color: "from-violet-500 to-indigo-600" },
{ title: "Branch and review", text: "Every change gets its own preview link. Teammates comment in context, right on the page.", icon: GitBranch, color: "from-sky-500 to-blue-600" },
{ title: "Check it automatically", text: "Accessibility, performance and broken links are checked on every push — before customers ever see them.", icon: ShieldCheck, color: "from-emerald-500 to-teal-600" },
{ title: "Ship worldwide", text: "Merge to main and your site is live on a global edge network in seconds, with instant rollbacks.", icon: Rocket, color: "from-orange-500 to-rose-600" },
];
type StickyScrollProps = {
items?: StickyItem[];
/** Scrollable element that acts as the viewport (defaults to the page) */
container?: RefObject<HTMLElement | null>;
/** Offset of the pinned visual, e.g. "top-24" when you have a sticky header */
stickyClassName?: string;
};
export function StickyScroll({ items = defaultItems, container, stickyClassName = "top-10" }: StickyScrollProps) {
const [active, setActive] = useState(0);
const current = items[active];
return (
<div className="grid gap-10 md:grid-cols-2">
<div>
{items.map((item, i) => (
<motion.div
key={item.title}
onViewportEnter={() => setActive(i)}
viewport={{ root: container, margin: "-45% 0px -45% 0px" }}
className="flex min-h-[280px] flex-col justify-center"
>
<motion.div animate={{ opacity: active === i ? 1 : 0.3 }} transition={{ duration: 0.3 }}>
<span className={`mb-4 grid size-10 place-items-center rounded-xl bg-gradient-to-br text-white md:hidden ${item.color}`}>
<item.icon className="size-5" />
</span>
<p className="text-sm font-medium text-zinc-400">0{i + 1}</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{item.title}</h3>
<p className="mt-3 max-w-sm leading-relaxed text-zinc-500">{item.text}</p>
</motion.div>
</motion.div>
))}
</div>
<div className="hidden md:block">
<div className={`sticky grid aspect-square max-h-[calc(100vh-9rem)] ${stickyClassName} place-items-center overflow-hidden rounded-3xl border border-zinc-200 bg-zinc-50`}>
<AnimatePresence mode="wait">
<motion.div
key={current.title}
initial={{ opacity: 0, scale: 0.8, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.8, rotate: 8 }}
transition={{ type: "spring", stiffness: 260, damping: 22 }}
className={`grid size-36 place-items-center rounded-[2rem] bg-gradient-to-br text-white shadow-2xl ${current.color}`}
>
<current.icon className="size-16" strokeWidth={1.5} />
</motion.div>
</AnimatePresence>
<div className="absolute bottom-6 flex gap-1.5">
{items.map((item, i) => (
<span key={item.title} className={`h-1.5 rounded-full transition-all duration-300 ${i === active ? "w-6 bg-zinc-900" : "w-1.5 bg-zinc-300"}`} />
))}
</div>
</div>
</div>
</div>
);
}
export default function Demo() {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} className="h-[440px] w-full max-w-4xl overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-8">
<StickyScroll container={ref} />
<div className="h-40" />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/sticky-scroll.tsxcomponents/ui/sticky-scroll.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { GitBranch, Rocket, ShieldCheck, Sparkles, type LucideIcon } from "lucide-react"; import { useRef, useState, type RefObject } from "react"; type StickyItem = { title: string; text: string; icon: LucideIcon; color: string }; const defaultItems: StickyItem[] = [ { title: "Write the idea", text: "Start from a prompt, a sketch or an existing page. The editor understands your intent and drafts the first version.", icon: Sparkles, color: "from-violet-500 to-indigo-600" }, { title: "Branch and review", text: "Every change gets its own preview link. Teammates comment in context, right on the page.", icon: GitBranch, color: "from-sky-500 to-blue-600" }, { title: "Check it automatically", text: "Accessibility, performance and broken links are checked on every push — before customers ever see them.", icon: ShieldCheck, color: "from-emerald-500 to-teal-600" }, { title: "Ship worldwide", text: "Merge to main and your site is live on a global edge network in seconds, with instant rollbacks.", icon: Rocket, color: "from-orange-500 to-rose-600" }, ]; type StickyScrollProps = { items?: StickyItem[]; /** Scrollable element that acts as the viewport (defaults to the page) */ container?: RefObject<HTMLElement | null>; /** Offset of the pinned visual, e.g. "top-24" when you have a sticky header */ stickyClassName?: string; }; export function StickyScroll({ items = defaultItems, container, stickyClassName = "top-10" }: StickyScrollProps) { const [active, setActive] = useState(0); const current = items[active]; return ( <div className="grid gap-10 md:grid-cols-2"> <div> {items.map((item, i) => ( <motion.div key={item.title} onViewportEnter={() => setActive(i)} viewport={{ root: container, margin: "-45% 0px -45% 0px" }} className="flex min-h-[280px] flex-col justify-center" > <motion.div animate={{ opacity: active === i ? 1 : 0.3 }} transition={{ duration: 0.3 }}> <span className={`mb-4 grid size-10 place-items-center rounded-xl bg-gradient-to-br text-white md:hidden ${item.color}`}> <item.icon className="size-5" /> </span> <p className="text-sm font-medium text-zinc-400">0{i + 1}</p> <h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{item.title}</h3> <p className="mt-3 max-w-sm leading-relaxed text-zinc-500">{item.text}</p> </motion.div> </motion.div> ))} </div> <div className="hidden md:block"> <div className={`sticky grid aspect-square max-h-[calc(100vh-9rem)] ${stickyClassName} place-items-center overflow-hidden rounded-3xl border border-zinc-200 bg-zinc-50`}> <AnimatePresence mode="wait"> <motion.div key={current.title} initial={{ opacity: 0, scale: 0.8, rotate: -8 }} animate={{ opacity: 1, scale: 1, rotate: 0 }} exit={{ opacity: 0, scale: 0.8, rotate: 8 }} transition={{ type: "spring", stiffness: 260, damping: 22 }} className={`grid size-36 place-items-center rounded-[2rem] bg-gradient-to-br text-white shadow-2xl ${current.color}`} > <current.icon className="size-16" strokeWidth={1.5} /> </motion.div> </AnimatePresence> <div className="absolute bottom-6 flex gap-1.5"> {items.map((item, i) => ( <span key={item.title} className={`h-1.5 rounded-full transition-all duration-300 ${i === active ? "w-6 bg-zinc-900" : "w-1.5 bg-zinc-300"}`} /> ))} </div> </div> </div> </div> ); } export default function Demo() { const ref = useRef<HTMLDivElement>(null); return ( <div ref={ref} className="h-[440px] w-full max-w-4xl overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-8"> <StickyScroll container={ref} /> <div className="h-40" /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.