Component

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.
01 · Copy-paste

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.

Drop-in components
02 · Conversion

Motion that earns its keep

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

Motion that earns its keep
03 · Performance

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.

Springs, not spinners
04 · Workflow

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.

From idea to shipped
Installation

01.Install Dependencies

npm install motion clsx tailwind-merge
Source Code
"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>
  );
}
sparkui