ScrollScroll Animationsno dependencies

ScrollTimeline

A vertical milestone timeline inside a smooth-scroll viewport: the line draws with scroll, and each dot lights up with a springy scale while its card slides in the moment the line tip reaches it.

Preview

Timeline

How we got here

  1. Milestone 01Idea on a napkin

    A late-night sketch of a motion library that stays out of the way.

  2. Milestone 02First prototype

    Twelve components, one rAF loop, zero dependencies beyond React.

  3. Milestone 03Private beta

    Forty studios stress-test every easing curve and edge case.

  4. Milestone 04Public launch

    Docs, playground and CLI ship the same afternoon.

  5. Milestone 0510k teams onboard

    Teams across product, marketing and games build with it daily.

  6. Milestone 06What comes next

    Scroll-driven scenes, physics and a visual timeline editor.

Customize

6 props
0.1
1

Installation

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

npx motionfoundry add scroll-timeline

Writes components/ui/scroll-timeline.tsx and components/styles/scroll-timeline.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
items

Milestone titles, one per timeline entry.

string[]['Idea on a napkin', 'First prototype', 'Private beta', 'Public launch', '10k teams onboard', 'What comes next']
descriptions

Optional description per milestone, matched by index (missing entries are omitted).

string[][…six default descriptions]
title

Heading shown above the timeline.

string'How we got here'
accentColor

Accent color for the drawn line, active dots and labels.

string'#34d399'
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" with an aria-label, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native, unsmoothed) and see a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the top/bottom edge wheel events are released so the page keeps scrolling. Touch and scrollbar dragging stay native. Under prefers-reduced-motion (or smooth=false) the wheel is not intercepted at all. Milestones are an ordered list with real headings text and descriptions that stay readable (dimmed, never hidden) before activation; the line and dots are aria-hidden. Under prefers-reduced-motion cards and dots change state without sliding or scaling.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/scroll-timeline.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-timeline.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  /** Signed scroll velocity in px per 60fps frame */14  velocity: number;15}16 17interface SmoothScrollOptions {18  lerp: number;19  wheelMultiplier: number;20  smooth: boolean;21  onScroll?: (state: SmoothScrollState) => void;22}23 24const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));25 26function usePrefersReducedMotion(): boolean {27  const [reduced, setReduced] = React.useState(false);28  React.useEffect(() => {29    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");30    const update = () => setReduced(mq.matches);31    update();32    mq.addEventListener("change", update);33    return () => mq.removeEventListener("change", update);34  }, []);35  return reduced;36}37 38/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */39function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {40  let el: Element | null = node instanceof Element ? node : null;41  while (el && el !== root) {42    if (el instanceof HTMLElement) {43      if (el.dataset.mfScrollPrevent !== undefined) return true;44      const overflowY = window.getComputedStyle(el).overflowY;45      if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {46        const canMove = dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1;47        if (canMove) return true;48      }49    }50    el = el.parentElement;51  }52  return false;53}54 55/**56 * Self-contained smooth scroller for an overflow container. Wheel input updates a target57 * position; a rAF loop eases the real scrollTop toward it (frame-rate independent lerp) and58 * stops once settled. Touch, keyboard and scrollbar drags stay native. At the top/bottom edge59 * wheel events are released so the page keeps scrolling.60 */61function useSmoothScroll(62  containerRef: React.RefObject<HTMLElement | null>,63  { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,64): React.RefObject<SmoothScrollState> {65  const stateRef = React.useRef<SmoothScrollState>({ scroll: 0, limit: 0, progress: 0, velocity: 0 });66  const callbackRef = React.useRef(onScroll);67  React.useEffect(() => {68    callbackRef.current = onScroll;69  }, [onScroll]);70 71  React.useEffect(() => {72    const el = containerRef.current;73    if (!el) return;74    const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);75    const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;76    let target = el.scrollTop;77    let current = target;78    let raf = 0;79    let lastTime = 0;80    let lastNative = el.scrollTop;81    let lastNativeTime = 0;82    let settleTimer: ReturnType<typeof setTimeout> | undefined;83 84    const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);85 86    const emit = (velocity: number) => {87      const limit = getLimit();88      const scroll = clamp(el.scrollTop, 0, limit);89      lastNative = el.scrollTop;90      const s = stateRef.current;91      s.scroll = scroll;92      s.limit = limit;93      s.progress = limit > 0 ? scroll / limit : 0;94      s.velocity = velocity;95      callbackRef.current?.(s);96    };97 98    const tick = (time: number) => {99      const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;100      lastTime = time;101      const limit = getLimit();102      target = clamp(target, 0, limit);103      if (Math.abs(el.scrollTop - current) > 2) {104        // Something else moved the viewport (scrollbar drag, keyboard, touch): hand control back.105        current = target = el.scrollTop;106        raf = 0;107        lastTime = 0;108        emit(0);109        return;110      }111      const prev = current;112      const frames = dt / 16.667;113      current += (target - current) * (1 - Math.pow(1 - ease, frames));114      if (Math.abs(target - current) < 0.35) current = target;115      el.scrollTop = current;116      emit((current - prev) / frames);117      if (current === target) {118        raf = 0;119        lastTime = 0;120        emit(0);121        return;122      }123      raf = requestAnimationFrame(tick);124    };125 126    const onWheel = (event: WheelEvent) => {127      if (event.ctrlKey || event.defaultPrevented) return;128      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;129      let dy = event.deltaY;130      if (event.deltaMode === 1) dy *= 16;131      else if (event.deltaMode === 2) dy *= el.clientHeight;132      dy *= multiplier;133      if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;134      const limit = getLimit();135      if (!raf) current = target = el.scrollTop;136      // Release to native scrolling at the edges so the page is never trapped.137      if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;138      event.preventDefault();139      target = clamp(target + dy, 0, limit);140      if (!raf) {141        lastTime = 0;142        raf = requestAnimationFrame(tick);143      }144    };145 146    const onNativeScroll = () => {147      if (raf) return;148      const top = el.scrollTop;149      const delta = top - lastNative;150      if (delta === 0) return;151      const now = performance.now();152      const dt = lastNativeTime ? clamp(now - lastNativeTime, 8, 100) : 16.667;153      lastNativeTime = now;154      current = target = top;155      emit(delta / (dt / 16.667));156      if (settleTimer) clearTimeout(settleTimer);157      settleTimer = setTimeout(() => {158        lastNativeTime = 0;159        emit(0);160      }, 90);161    };162 163    if (smooth) el.addEventListener("wheel", onWheel, { passive: false });164    el.addEventListener("scroll", onNativeScroll, { passive: true });165    const ro = new ResizeObserver(() => emit(stateRef.current.velocity));166    ro.observe(el);167    if (el.firstElementChild) ro.observe(el.firstElementChild);168    emit(0);169 170    return () => {171      if (smooth) el.removeEventListener("wheel", onWheel);172      el.removeEventListener("scroll", onNativeScroll);173      ro.disconnect();174      if (raf) cancelAnimationFrame(raf);175      if (settleTimer) clearTimeout(settleTimer);176    };177  }, [containerRef, lerp, wheelMultiplier, smooth]);178 179  return stateRef;180}181 182export interface ScrollTimelineProps {183  /** Milestone titles, one per timeline entry */184  items?: string[];185  /** Optional description per milestone (matched by index) */186  descriptions?: string[];187  /** Heading shown above the timeline */188  title?: string;189  /** Accent color for the drawn line and active dots */190  accentColor?: string;191  /** Viewport height in px */192  height?: number;193  /** Viewport corner radius in px */194  borderRadius?: number;195  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */196  lerp?: number;197  /** Multiplier applied to wheel / trackpad deltas */198  wheelMultiplier?: number;199  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */200  smooth?: boolean;201  /** Optional className merged onto the root */202  className?: string;203  /** Inline styles merged onto the root */204  style?: React.CSSProperties;205}206 207const DEFAULT_ITEMS = [208  "Idea on a napkin",209  "First prototype",210  "Private beta",211  "Public launch",212  "10k teams onboard",213  "What comes next",214];215 216const DEFAULT_DESCRIPTIONS = [217  "A late-night sketch of a motion library that stays out of the way.",218  "Twelve components, one rAF loop, zero dependencies beyond React.",219  "Forty studios stress-test every easing curve and edge case.",220  "Docs, playground and CLI ship the same afternoon.",221  "Teams across product, marketing and games build with it daily.",222  "Scroll-driven scenes, physics and a visual timeline editor.",223];224 225/** Fraction of the viewport height where the line tip sits. */226const ANCHOR = 0.6;227 228export function ScrollTimeline({229  items = DEFAULT_ITEMS,230  descriptions = DEFAULT_DESCRIPTIONS,231  title = "How we got here",232  accentColor = "#34d399",233  height = 380,234  borderRadius = 20,235  lerp = 0.1,236  wheelMultiplier = 1,237  smooth = true,238  className = "",239  style,240}: ScrollTimelineProps) {241  const reduced = usePrefersReducedMotion();242  const rootRef = React.useRef<HTMLDivElement>(null);243  const viewportRef = React.useRef<HTMLDivElement>(null);244  const listRef = React.useRef<HTMLDivElement>(null);245  const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);246  const geometryRef = React.useRef({ listTop: 0, listHeight: 1, dots: [] as number[] });247  const stateRef = React.useRef({ scroll: 0, progress: 0, viewport: 0 });248  const total = items.length;249 250  const apply = React.useCallback(() => {251    const root = rootRef.current;252    if (!root) return;253    const { listTop, listHeight, dots } = geometryRef.current;254    const { scroll, progress, viewport } = stateRef.current;255    const anchor = progress >= 0.999 ? Number.POSITIVE_INFINITY : scroll + viewport * ANCHOR - listTop;256    const fill = clamp(anchor / listHeight, 0, 1);257    root.style.setProperty("--mf-st-fill", fill.toFixed(4));258    for (let i = 0; i < total; i++) {259      const item = itemRefs.current[i];260      if (!item) continue;261      const active = anchor >= (dots[i] ?? 0);262      if ((item.dataset.active === "true") !== active) item.dataset.active = active ? "true" : "false";263    }264  }, [total]);265 266  const handleScroll = React.useCallback(267    (state: SmoothScrollState) => {268      stateRef.current.scroll = state.scroll;269      stateRef.current.progress = state.progress;270      apply();271    },272    [apply],273  );274 275  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });276 277  React.useEffect(() => {278    const list = listRef.current;279    const viewport = viewportRef.current;280    if (!list || !viewport) return;281    const measure = () => {282      geometryRef.current = {283        listTop: list.offsetTop,284        listHeight: Math.max(1, list.offsetHeight),285        dots: itemRefs.current.slice(0, total).map((node) => (node ? node.offsetTop + 22 : 0)),286      };287      stateRef.current.viewport = viewport.clientHeight;288      apply();289    };290    measure();291    const ro = new ResizeObserver(measure);292    ro.observe(list);293    ro.observe(viewport);294    return () => ro.disconnect();295  }, [total, apply]);296 297  const vars = {298    "--mf-st-h": `${height}px`,299    "--mf-st-r": `${borderRadius}px`,300    "--mf-st-accent": accentColor,301    ...style,302  } as React.CSSProperties;303 304  return (305    <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>306      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>307        <div className={styles.content}>308          <header className={styles.header}>309            <span className={styles.kicker}>Timeline</span>310            <h3 className={styles.title}>{title}</h3>311          </header>312          <div ref={listRef} className={styles.timeline}>313            <span aria-hidden="true" className={styles.line}>314              <span className={styles.lineFill} />315            </span>316            <ol className={styles.list}>317              {items.map((item, i) => (318                <li319                  key={`${item}-${i}`}320                  ref={(node) => {321                    itemRefs.current[i] = node;322                  }}323                  className={styles.item}324                >325                  <span aria-hidden="true" className={styles.dot} />326                  <div className={styles.card}>327                    <span className={styles.step}>{`Milestone ${String(i + 1).padStart(2, "0")}`}</span>328                    <span className={styles.itemTitle}>{item}</span>329                    {descriptions[i] && <p className={styles.description}>{descriptions[i]}</p>}330                  </div>331                </li>332              ))}333            </ol>334          </div>335        </div>336      </div>337    </div>338  );339}