ScrollScroll Animationsno dependencies

PinnedSteps

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

Step 1 of 4

How it works

  1. Plug in databases, events and spreadsheets in a couple of clicks. Schemas sync automatically.

  2. Group raw events into the metrics your team actually talks about, with zero SQL.

  3. Live charts update as data lands, so changes surface the minute they happen.

  4. Share a link, schedule a digest or push alerts straight to the channel that cares.

Customize

9 props
450
0.1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add pinned-steps

Writes 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.

example.tsx
1import { PinnedSteps } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <PinnedSteps />6  );7}

Props

PropTypeDefault
steps

Step titles; each gets an equal share of the scroll distance and its own illustration.

string[]['Connect your sources', 'Map the signals', 'Watch it trend', 'Ship the insight']
title

Heading above the step list; also used as the scroll region's aria-label.

string'How it works'
accentColor

Accent color for the active step badge, progress lines and illustrations.

string'#34d399'
transition

How the right-hand visual changes between steps.

'fade' | 'slide' | 'zoom''slide'
transitionDuration

Duration in ms of the visual crossfade.

number450
showProgress

Show the progress line inside the active step and the overall progress bar on the visual.

booleantrue
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/pinned-steps.tsx
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}