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.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/sticky-scroll.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.