Web InnoventixFreeCode

Pinned Scroll Steps

Original · free

A pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.

byWeb InnoventixReact + Tailwind
scrollstickystepsscrollytelling
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scroll-pinned-steps.json
scroll-pinned-steps.tsx
"use client";

import { useRef, useState, type ReactNode } from "react";
import {
  motion,
  useScroll,
  useTransform,
  useSpring,
  useMotionValueEvent,
  AnimatePresence,
} from "motion/react";

type Step = {
  id: string;
  kicker: string;
  title: string;
  body: string;
  metric: { value: string; label: string };
  accent: {
    text: string;
    ring: string;
    dot: string;
    glow: string;
    bar: string;
  };
  visual: ReactNode;
};

function VisualCapture() {
  return (
    <div className="absolute inset-0 grid place-items-center overflow-hidden">
      <div
        className="absolute inset-0 bg-[radial-gradient(120%_120%_at_20%_10%,#e0e7ff_0%,transparent_55%),radial-gradient(120%_120%_at_90%_90%,#f5d0fe_0%,transparent_50%)] dark:bg-[radial-gradient(120%_120%_at_20%_10%,#1e1b4b_0%,transparent_55%),radial-gradient(120%_120%_at_90%_90%,#3b0764_0%,transparent_50%)]"
        aria-hidden
      />
      <div className="relative w-[74%] max-w-sm rounded-2xl border border-indigo-200/70 bg-white/80 p-4 shadow-2xl shadow-indigo-500/20 backdrop-blur dark:border-indigo-400/20 dark:bg-slate-900/70">
        <div className="mb-3 flex items-center gap-1.5" aria-hidden>
          <span className="h-2.5 w-2.5 rounded-full bg-rose-400" />
          <span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
          <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
        </div>
        <div className="space-y-2.5">
          {[92, 68, 80, 54].map((w, i) => (
            <div key={i} className="flex items-center gap-2.5">
              <span className="h-6 w-6 shrink-0 rounded-md bg-gradient-to-br from-indigo-400 to-violet-500" />
              <span
                className="h-2.5 rounded-full bg-indigo-100 dark:bg-indigo-500/20"
                style={{ width: `${w}%` }}
              />
            </div>
          ))}
        </div>
        <div className="szh-scan pointer-events-none absolute inset-x-4 top-4 h-16 rounded-xl bg-gradient-to-b from-indigo-400/25 to-transparent" />
      </div>
    </div>
  );
}

function VisualStructure() {
  return (
    <div className="absolute inset-0 grid place-items-center overflow-hidden">
      <div
        className="absolute inset-0 bg-[radial-gradient(120%_120%_at_80%_10%,#cffafe_0%,transparent_55%),radial-gradient(120%_120%_at_10%_90%,#a5f3fc_0%,transparent_45%)] dark:bg-[radial-gradient(120%_120%_at_80%_10%,#083344_0%,transparent_55%),radial-gradient(120%_120%_at_10%_90%,#0c4a6e_0%,transparent_45%)]"
        aria-hidden
      />
      <svg
        viewBox="0 0 320 240"
        className="relative w-[78%] max-w-md drop-shadow-xl"
        role="img"
        aria-label="Connected node graph"
      >
        <defs>
          <linearGradient id="szh-edge" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" stopColor="#38bdf8" />
            <stop offset="1" stopColor="#0ea5e9" />
          </linearGradient>
        </defs>
        {[
          [160, 48, 70, 130],
          [160, 48, 250, 130],
          [70, 130, 40, 205],
          [70, 130, 120, 205],
          [250, 130, 210, 205],
          [250, 130, 290, 205],
        ].map(([x1, y1, x2, y2], i) => (
          <line
            key={i}
            x1={x1}
            y1={y1}
            x2={x2}
            y2={y2}
            stroke="url(#szh-edge)"
            strokeWidth={2}
            strokeLinecap="round"
            className="szh-draw"
            style={{ animationDelay: `${i * 0.12}s` }}
          />
        ))}
        {[
          [160, 48, 15],
          [70, 130, 11],
          [250, 130, 11],
          [40, 205, 8],
          [120, 205, 8],
          [210, 205, 8],
          [290, 205, 8],
        ].map(([cx, cy, r], i) => (
          <circle
            key={i}
            cx={cx}
            cy={cy}
            r={r}
            className="fill-white stroke-sky-500 dark:fill-slate-900"
            strokeWidth={2.5}
          />
        ))}
      </svg>
    </div>
  );
}

function VisualScale() {
  return (
    <div className="absolute inset-0 grid place-items-center overflow-hidden">
      <div
        className="absolute inset-0 bg-[radial-gradient(120%_120%_at_20%_15%,#d1fae5_0%,transparent_55%),radial-gradient(120%_120%_at_90%_85%,#a7f3d0_0%,transparent_45%)] dark:bg-[radial-gradient(120%_120%_at_20%_15%,#022c22_0%,transparent_55%),radial-gradient(120%_120%_at_90%_85%,#064e3b_0%,transparent_45%)]"
        aria-hidden
      />
      <div className="relative flex h-[62%] w-[78%] max-w-md items-end gap-3 rounded-2xl border border-emerald-200/70 bg-white/70 p-5 shadow-xl backdrop-blur dark:border-emerald-400/20 dark:bg-slate-900/60">
        {[38, 55, 44, 72, 61, 88, 96].map((h, i) => (
          <div key={i} className="flex flex-1 flex-col items-center gap-2">
            <div
              className="szh-grow w-full origin-bottom rounded-t-md bg-gradient-to-t from-emerald-500 to-teal-400"
              style={{ height: `${h}%`, animationDelay: `${i * 0.09}s` }}
            />
          </div>
        ))}
        <svg
          viewBox="0 0 100 60"
          preserveAspectRatio="none"
          className="pointer-events-none absolute inset-5 h-[calc(100%-2.5rem)] w-[calc(100%-2.5rem)]"
          aria-hidden
        >
          <path
            d="M4 46 L18 38 L32 42 L46 26 L60 32 L74 14 L92 5"
            fill="none"
            stroke="#0f766e"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
            className="szh-draw"
          />
        </svg>
      </div>
    </div>
  );
}

function VisualShip() {
  return (
    <div className="absolute inset-0 grid place-items-center overflow-hidden">
      <div
        className="absolute inset-0 bg-[radial-gradient(120%_120%_at_75%_15%,#fae8ff_0%,transparent_55%),radial-gradient(120%_120%_at_15%_90%,#fbcfe8_0%,transparent_45%)] dark:bg-[radial-gradient(120%_120%_at_75%_15%,#4a044e_0%,transparent_55%),radial-gradient(120%_120%_at_15%_90%,#500724_0%,transparent_45%)]"
        aria-hidden
      />
      <div className="relative grid w-[74%] max-w-sm gap-3">
        {[
          { name: "Build", done: true },
          { name: "Test", done: true },
          { name: "Deploy", done: true },
          { name: "Live", done: false },
        ].map((s, i) => (
          <div
            key={s.name}
            className="szh-rise flex items-center justify-between rounded-xl border border-fuchsia-200/70 bg-white/80 px-4 py-3 shadow-lg backdrop-blur dark:border-fuchsia-400/20 dark:bg-slate-900/70"
            style={{ animationDelay: `${i * 0.12}s` }}
          >
            <span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
              {s.name}
            </span>
            {s.done ? (
              <span className="grid h-6 w-6 place-items-center rounded-full bg-fuchsia-500 text-white">
                <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" aria-hidden>
                  <path
                    d="M5 13l4 4L19 7"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={3}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              </span>
            ) : (
              <span className="szh-pulse h-6 w-6 rounded-full border-2 border-dashed border-fuchsia-400" />
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

const STEPS: Step[] = [
  {
    id: "capture",
    kicker: "01 — Capture",
    title: "Pull every signal into one clean workspace",
    body: "Forms, calls, chats and imports land in a single inbox. No copy-paste, no lost context — each lead arrives enriched and ready to work.",
    metric: { value: "4.2s", label: "avg. intake time" },
    accent: {
      text: "text-indigo-600 dark:text-indigo-300",
      ring: "ring-indigo-500/30",
      dot: "bg-indigo-500",
      glow: "shadow-indigo-500/25",
      bar: "from-indigo-500 to-violet-500",
    },
    visual: <VisualCapture />,
  },
  {
    id: "structure",
    kicker: "02 — Structure",
    title: "Map the messy middle into a clear pipeline",
    body: "Auto-routing connects each record to the right owner and stage. Relationships stay intact so nothing falls through the cracks between teams.",
    metric: { value: "100%", label: "records routed" },
    accent: {
      text: "text-sky-600 dark:text-sky-300",
      ring: "ring-sky-500/30",
      dot: "bg-sky-500",
      glow: "shadow-sky-500/25",
      bar: "from-sky-500 to-cyan-500",
    },
    visual: <VisualStructure />,
  },
  {
    id: "scale",
    kicker: "03 — Scale",
    title: "Watch throughput compound week over week",
    body: "Playbooks fire automatically as volume grows. The system does more of the repetitive work so your team spends its hours where judgment matters.",
    metric: { value: "3.1×", label: "output per rep" },
    accent: {
      text: "text-emerald-600 dark:text-emerald-300",
      ring: "ring-emerald-500/30",
      dot: "bg-emerald-500",
      glow: "shadow-emerald-500/25",
      bar: "from-emerald-500 to-teal-500",
    },
    visual: <VisualScale />,
  },
  {
    id: "ship",
    kicker: "04 — Ship",
    title: "Move from build to live without the drama",
    body: "One click promotes work through build, test and deploy. Confidence checks run in the background so every release goes out clean.",
    metric: { value: "0", label: "manual handoffs" },
    accent: {
      text: "text-fuchsia-600 dark:text-fuchsia-300",
      ring: "ring-fuchsia-500/30",
      dot: "bg-fuchsia-500",
      glow: "shadow-fuchsia-500/25",
      bar: "from-fuchsia-500 to-pink-500",
    },
    visual: <VisualShip />,
  },
];

export default function ScrollPinnedSteps() {
  const wrapRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState<number>(0);

  const { scrollYProgress } = useScroll({
    target: wrapRef,
    offset: ["start start", "end end"],
  });

  const progress = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 26,
    mass: 0.4,
  });

  const barScaleX = useTransform(progress, [0, 1], [0, 1]);

  useMotionValueEvent(scrollYProgress, "change", (v: number) => {
    const next = Math.min(
      STEPS.length - 1,
      Math.max(0, Math.floor(v * STEPS.length))
    );
    if (next !== active) setActive(next);
  });

  const current = STEPS[active];

  return (
    <section className="relative w-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes szh-scan {
          0% { transform: translateY(0); opacity: 0.9; }
          100% { transform: translateY(150px); opacity: 0; }
        }
        @keyframes szh-draw {
          from { stroke-dashoffset: 400; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes szh-grow {
          from { transform: scaleY(0); }
          to { transform: scaleY(1); }
        }
        @keyframes szh-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes szh-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(0.85); }
        }
        .szh-scan { animation: szh-scan 2.4s ease-in-out infinite; }
        .szh-draw { stroke-dasharray: 400; animation: szh-draw 1.1s ease-out forwards; }
        .szh-grow { animation: szh-grow 0.7s cubic-bezier(0.22,1,0.36,1) forwards; }
        .szh-rise { animation: szh-rise 0.5s ease-out forwards; }
        .szh-pulse { animation: szh-pulse 1.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .szh-scan, .szh-draw, .szh-grow, .szh-rise, .szh-pulse {
            animation: none !important;
          }
          .szh-draw { stroke-dashoffset: 0 !important; }
          .szh-grow { transform: scaleY(1) !important; }
          .szh-rise { opacity: 1 !important; transform: none !important; }
        }
      `}</style>

      <div ref={wrapRef} className="relative h-[320vh]">
        <div className="sticky top-0 flex min-h-screen flex-col overflow-hidden">
          <div className="pointer-events-none absolute inset-0" aria-hidden>
            <div className="absolute -left-24 top-1/4 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10" />
            <div className="absolute -right-24 bottom-1/4 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl dark:bg-fuchsia-500/10" />
          </div>

          <div className="mx-auto flex w-full max-w-7xl flex-1 flex-col px-6 py-14 sm:px-10 lg:py-20">
            <header className="mb-8 flex flex-col gap-4 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
              <div>
                <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
                  <span className={`h-1.5 w-1.5 rounded-full ${current.accent.dot}`} />
                  How it works
                </span>
                <h2 className="mt-4 max-w-xl text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
                  From first touch to shipped,
                  <span className={current.accent.text}> one flow</span>.
                </h2>
              </div>

              <div className="flex items-center gap-3">
                {STEPS.map((s, i) => (
                  <span
                    key={s.id}
                    aria-hidden
                    className={`h-1.5 rounded-full transition-all duration-500 ${
                      i === active
                        ? `w-10 ${s.accent.dot}`
                        : "w-4 bg-slate-300 dark:bg-slate-700"
                    }`}
                  />
                ))}
              </div>
            </header>

            <div className="grid flex-1 items-center gap-8 lg:grid-cols-2 lg:gap-14">
              <div className="order-2 lg:order-1">
                <div className="relative min-h-[13rem]">
                  <AnimatePresence mode="wait">
                    <motion.div
                      key={current.id}
                      initial={{ opacity: 0, y: 24 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -18 }}
                      transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
                    >
                      <p className={`text-sm font-bold uppercase tracking-widest ${current.accent.text}`}>
                        {current.kicker}
                      </p>
                      <h3 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
                        {current.title}
                      </h3>
                      <p className="mt-4 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
                        {current.body}
                      </p>
                      <div
                        className={`mt-6 inline-flex items-baseline gap-3 rounded-2xl bg-white/70 px-5 py-3 shadow-lg ring-1 ${current.accent.ring} ${current.accent.glow} backdrop-blur dark:bg-slate-900/60`}
                      >
                        <span className={`text-3xl font-bold tabular-nums ${current.accent.text}`}>
                          {current.metric.value}
                        </span>
                        <span className="text-sm text-slate-500 dark:text-slate-400">
                          {current.metric.label}
                        </span>
                      </div>
                    </motion.div>
                  </AnimatePresence>
                </div>

                <ol className="mt-8 space-y-1">
                  {STEPS.map((s, i) => (
                    <li key={s.id}>
                      <div
                        className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors duration-300 ${
                          i === active
                            ? "bg-white/70 dark:bg-slate-900/60"
                            : "opacity-55"
                        }`}
                      >
                        <span
                          className={`grid h-6 w-6 shrink-0 place-items-center rounded-full text-xs font-bold text-white transition-colors duration-300 ${
                            i <= active ? s.accent.dot : "bg-slate-300 dark:bg-slate-700"
                          }`}
                        >
                          {i + 1}
                        </span>
                        <span
                          className={`text-sm font-medium ${
                            i === active
                              ? "text-slate-900 dark:text-slate-100"
                              : "text-slate-500 dark:text-slate-500"
                          }`}
                        >
                          {s.title}
                        </span>
                      </div>
                    </li>
                  ))}
                </ol>
              </div>

              <div className="order-1 lg:order-2">
                <div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl border border-slate-200/80 bg-white/50 shadow-2xl shadow-slate-900/10 backdrop-blur dark:border-slate-800 dark:bg-slate-900/40 dark:shadow-black/40">
                  <AnimatePresence>
                    <motion.div
                      key={current.id}
                      className="absolute inset-0"
                      initial={{ opacity: 0, scale: 1.04 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, scale: 0.98 }}
                      transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
                    >
                      {current.visual}
                    </motion.div>
                  </AnimatePresence>
                  <div className="pointer-events-none absolute inset-0 rounded-3xl ring-1 ring-inset ring-white/40 dark:ring-white/5" />
                </div>
              </div>
            </div>

            <div className="mt-10 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
              <motion.div
                className={`h-full origin-left rounded-full bg-gradient-to-r ${current.accent.bar}`}
                style={{ scaleX: barScaleX }}
                aria-hidden
              />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →