Pinned Scroll Steps
Original · freeA pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.
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"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
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 quoteSimilar components
Browse all →
Scroll Progress Reveal
OriginalA section that reveals its content in sequence as you scroll, with a sticky scroll-progress bar showing how far through it you are.

Parallax Layers
OriginalA layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

Horizontal Scroll Gallery
OriginalVertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

Scroll Reveal Timeline
OriginalA vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

Count-up Stats Band
OriginalA metrics band whose numbers count up from zero the moment it scrolls into view.

Clip-path Image Reveal
OriginalPanels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

Stacking Cards
OriginalA deck of cards that pin and scale as you scroll, each settling behind the next.

Scroll Text Highlight
OriginalA statement whose words light up one by one as you scroll through the section.

Scroll Zoom Hero
OriginalA hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

