A pinned two-column walkthrough inside its own smooth-scroll viewport: scrolling advances through the steps, highlighting and expanding the active step with a live progress line while the visual on the right crossfades to that step's illustration.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add pinned-stepsWrites components/ui/pinned-steps.tsx and components/styles/pinned-steps.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { PinnedSteps } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <PinnedSteps />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrow/Page keys/Space (native, unsmoothed) with a 2px :focus-visible outline. Steps are an ordered list of buttons; the active one has aria-current="step" and activating any step scrolls the viewport to it. The "Step n of N" kicker is real text. Wheel input is smoothed only while the viewport can still move; at the edges wheel events are released to the page, and touch and scrollbar dragging stay native. The right-hand illustration, progress bars and dots are decorative and aria-hidden. Under prefers-reduced-motion the wheel is not intercepted, step clicks jump instantly and visuals switch without sliding, zooming or fading.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/pinned-steps.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 if (el.dataset.mfScrollPrevent !== undefined) return true;42 const oy = window.getComputedStyle(el).overflowY;43 if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {44 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;45 }46 }47 el = el.parentElement;48 }49 return false;50}51 52/**53 * Self-contained smooth scroller for an overflow container: wheel input sets a target, a rAF54 * loop eases scrollTop toward it (frame-rate independent) and stops when settled. Touch,55 * keyboard and scrollbar stay native; at the edges wheel events are released to the page.56 */57function useSmoothScroll(58 containerRef: React.RefObject<HTMLElement | null>,59 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,60) {61 const callbackRef = React.useRef(onScroll);62 React.useEffect(() => {63 callbackRef.current = onScroll;64 }, [onScroll]);65 66 React.useEffect(() => {67 const el = containerRef.current;68 if (!el) return;69 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);70 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;71 let target = el.scrollTop;72 let current = target;73 let raf = 0;74 let lastTime = 0;75 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);76 const emit = () => {77 const limit = getLimit();78 const scroll = clamp(el.scrollTop, 0, limit);79 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });80 };81 const stop = () => {82 raf = 0;83 lastTime = 0;84 };85 const tick = (time: number) => {86 const frames = (lastTime ? clamp(time - lastTime, 1, 64) : 16.667) / 16.667;87 lastTime = time;88 target = clamp(target, 0, getLimit());89 if (Math.abs(el.scrollTop - current) > 2) {90 // Scrollbar drag, keyboard or touch moved the viewport: hand control back.91 current = target = el.scrollTop;92 stop();93 emit();94 return;95 }96 current += (target - current) * (1 - Math.pow(1 - ease, frames));97 if (Math.abs(target - current) < 0.35) current = target;98 el.scrollTop = current;99 emit();100 if (current === target) stop();101 else raf = requestAnimationFrame(tick);102 };103 const onWheel = (event: WheelEvent) => {104 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;105 let dy = event.deltaY;106 if (event.deltaMode === 1) dy *= 16;107 else if (event.deltaMode === 2) dy *= el.clientHeight;108 dy *= multiplier;109 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;110 const limit = getLimit();111 if (!raf) current = target = el.scrollTop;112 // Release to native scrolling at the edges so the page is never trapped.113 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;114 event.preventDefault();115 target = clamp(target + dy, 0, limit);116 if (!raf) raf = requestAnimationFrame(tick);117 };118 const onNativeScroll = () => {119 if (raf) return;120 current = target = el.scrollTop;121 emit();122 };123 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });124 el.addEventListener("scroll", onNativeScroll, { passive: true });125 const ro = new ResizeObserver(emit);126 ro.observe(el);127 if (el.firstElementChild) ro.observe(el.firstElementChild);128 emit();129 return () => {130 if (smooth) el.removeEventListener("wheel", onWheel);131 el.removeEventListener("scroll", onNativeScroll);132 ro.disconnect();133 if (raf) cancelAnimationFrame(raf);134 };135 }, [containerRef, lerp, wheelMultiplier, smooth]);136}137 138export interface PinnedStepsProps {139 /** Step titles, one pinned step per item */140 steps?: string[];141 /** Heading shown above the step list (also the region label) */142 title?: string;143 /** Accent color for the active step, progress and visuals */144 accentColor?: string;145 /** How the right-hand visual changes between steps */146 transition?: "fade" | "slide" | "zoom";147 /** Duration in ms of the visual crossfade */148 transitionDuration?: number;149 /** Show the progress line inside the active step and the overall bar */150 showProgress?: boolean;151 /** Viewport height in px */152 height?: number;153 /** Viewport corner radius in px */154 borderRadius?: number;155 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */156 lerp?: number;157 /** Multiplier applied to wheel / trackpad deltas */158 wheelMultiplier?: number;159 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */160 smooth?: boolean;161 /** Optional className merged onto the root */162 className?: string;163 /** Inline styles merged onto the root */164 style?: React.CSSProperties;165}166 167const DEFAULT_STEPS = ["Connect your sources", "Map the signals", "Watch it trend", "Ship the insight"];168const DESCRIPTIONS = [169 "Plug in databases, events and spreadsheets in a couple of clicks. Schemas sync automatically.",170 "Group raw events into the metrics your team actually talks about, with zero SQL.",171 "Live charts update as data lands, so changes surface the minute they happen.",172 "Share a link, schedule a digest or push alerts straight to the channel that cares.",173];174const NODES: [number, number][] = [175 [40, 60],176 [100, 30],177 [160, 60],178 [100, 90],179];180 181function StepArt({ kind }: { kind: number }) {182 if (kind === 0) {183 return (184 <svg viewBox="0 0 200 120" className={styles.art}>185 <path d="M40 60 L100 30 L160 60 L100 90 Z M40 60 L160 60 M100 30 L100 90" className={styles.stroke} />186 {NODES.map(([x, y]) => (187 <circle key={`${x}-${y}`} cx={x} cy={y} r="11" className={styles.node} />188 ))}189 <circle cx="100" cy="60" r="16" className={styles.hub} />190 </svg>191 );192 }193 if (kind === 1) {194 return (195 <svg viewBox="0 0 200 120" className={styles.art}>196 {[48, 80, 36, 96, 64, 88, 56].map((h, b) => (197 <rect key={b} x={22 + b * 23} y={104 - h} width="15" height={h} rx="4" className={b === 3 ? styles.hub : styles.node} />198 ))}199 </svg>200 );201 }202 if (kind === 2) {203 return (204 <svg viewBox="0 0 200 120" className={styles.art}>205 <path d="M14 96 C 44 90, 54 60, 84 66 S 130 30, 186 20 L186 108 L14 108 Z" className={styles.area} />206 <path d="M14 96 C 44 90, 54 60, 84 66 S 130 30, 186 20" className={styles.stroke} />207 <circle cx="186" cy="20" r="6" className={styles.hub} />208 </svg>209 );210 }211 return (212 <svg viewBox="0 0 200 120" className={styles.art}>213 <circle cx="100" cy="60" r="40" className={styles.area} />214 <circle cx="100" cy="60" r="40" className={styles.stroke} />215 <path d="M82 61 L95 74 L120 47" className={styles.check} />216 </svg>217 );218}219 220export function PinnedSteps({221 steps = DEFAULT_STEPS,222 title = "How it works",223 accentColor = "#34d399",224 transition = "slide",225 transitionDuration = 450,226 showProgress = true,227 height = 380,228 borderRadius = 20,229 lerp = 0.1,230 wheelMultiplier = 1,231 smooth = true,232 className = "",233 style,234}: PinnedStepsProps) {235 const reduced = usePrefersReducedMotion();236 const viewportRef = React.useRef<HTMLDivElement>(null);237 const stageRef = React.useRef<HTMLDivElement>(null);238 const [active, setActive] = React.useState(0);239 const total = Math.max(1, steps.length);240 const perStep = Math.round(height * 0.8);241 242 const handleScroll = React.useCallback(243 ({ progress }: SmoothScrollState) => {244 const t = progress * total;245 const index = clamp(Math.floor(t), 0, total - 1);246 const stage = stageRef.current;247 if (stage) {248 stage.style.setProperty("--mf-pst-p", progress.toFixed(4));249 stage.style.setProperty("--mf-pst-l", clamp(t - index, 0, 1).toFixed(4));250 }251 setActive(index);252 },253 [total],254 );255 256 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });257 258 const goTo = (i: number) => {259 const el = viewportRef.current;260 if (!el) return;261 const limit = el.scrollHeight - el.clientHeight;262 el.scrollTo({ top: ((i + 0.04) / total) * limit, behavior: reduced ? "auto" : "smooth" });263 };264 265 const vars = {266 "--mf-pst-h": `${height}px`,267 "--mf-pst-r": `${borderRadius}px`,268 "--mf-pst-accent": accentColor,269 "--mf-pst-dur": `${Math.max(0, transitionDuration)}ms`,270 ...style,271 } as React.CSSProperties;272 273 return (274 <div style={vars} className={`${styles.root} ${className}`.trim()} data-transition={transition}>275 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>276 <div style={{ height: height + total * perStep }}>277 <div ref={stageRef} className={styles.stage}>278 <div className={styles.left}>279 <span className={styles.kicker}>{`Step ${active + 1} of ${total}`}</span>280 <h3 className={styles.title}>{title}</h3>281 <ol className={styles.steps}>282 {steps.map((step, i) => (283 <li key={`${step}-${i}`} className={styles.step} data-state={i === active ? "active" : i < active ? "done" : "todo"}>284 <button285 type="button"286 className={styles.stepButton}287 aria-current={i === active ? "step" : undefined}288 onClick={() => goTo(i)}289 >290 <span className={styles.badge} aria-hidden="true">291 {i < active ? "✓" : i + 1}292 </span>293 <span className={styles.stepTitle}>{step}</span>294 </button>295 <div className={styles.detail}>296 <div className={styles.detailInner}>297 {showProgress && (298 <span className={styles.line} aria-hidden="true">299 <span className={styles.lineFill} />300 </span>301 )}302 <p className={styles.desc}>{DESCRIPTIONS[i % DESCRIPTIONS.length]}</p>303 </div>304 </div>305 </li>306 ))}307 </ol>308 </div>309 <div className={styles.right} aria-hidden="true">310 <div className={styles.frame}>311 <div className={styles.chrome}>312 <span className={styles.light} />313 <span className={styles.light} />314 <span className={styles.light} />315 {showProgress && (316 <span className={styles.bar}>317 <span className={styles.barFill} />318 </span>319 )}320 </div>321 {steps.map((step, i) => (322 <div323 key={`${step}-v-${i}`}324 className={styles.visual}325 data-state={i === active ? "active" : i < active ? "before" : "after"}326 style={{ "--mf-pst-hue": `${i * 38}deg` } as React.CSSProperties}327 >328 <StepArt kind={i % 4} />329 <span className={styles.visualLabel}>{step}</span>330 </div>331 ))}332 </div>333 <div className={styles.dots}>334 {steps.map((step, i) => (335 <span key={`${step}-d-${i}`} className={styles.dot} data-active={i === active ? "" : undefined} />336 ))}337 </div>338 </div>339 </div>340 </div>341 </div>342 </div>343 );344}