ScrollScroll Animationsno dependencies

ScrollWordSwap

A big pinned headline inside its own smooth-scroll viewport whose key word swaps once per scroll section with a masked slide, blur or flip, while the word color, background wash and segmented progress bar shift to each word's tint.

Preview

01 / 05

Interfaces that feel fast, alive, honest, effortless, yours.

Customize

9 props
600
0.1
1

Installation

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

npx motionfoundry add scroll-word-swap

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

Usage

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

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

Props

PropTypeDefault
prefix

Text before the swapping word.

string'Interfaces that feel'
words

Words swapped in, one per equal scroll section.

string[]['fast', 'alive', 'honest', 'effortless', 'yours']
suffix

Text after the swapping word.

string'.'
colors

Tint per word (cycled) used for the word color, background wash and progress fill.

string[]['#38bdf8', '#f472b6', '#fbbf24', '#34d399', '#a78bfa']
effect

Transition used when the word swaps; all are clipped by the word's mask slot.

'slide' | 'blur' | 'flip''slide'
duration

Swap transition duration in ms (the background wash crossfades slightly slower).

number600
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" whose aria-label carries the full sentence with every word, so keyboard users can Tab to it and scroll with arrow/Page keys/Space (native, unsmoothed) with a 2px :focus-visible outline. The heading exposes the complete text (prefix, all words, suffix) through a visually hidden span while the animated word stack is aria-hidden, so screen readers are not spammed while scrolling. 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. Background washes and progress segments are decorative. Under prefers-reduced-motion the wheel is not intercepted and words switch instantly without slide, blur, flip or color transitions.

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-word-swap.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-word-swap.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 ScrollWordSwapProps {139  /** Text before the swapping word */140  prefix?: string;141  /** Words swapped in one per scroll section */142  words?: string[];143  /** Text after the swapping word */144  suffix?: string;145  /** Tint colors per word (word color and background wash), cycled */146  colors?: string[];147  /** Transition used when the word swaps */148  effect?: "slide" | "blur" | "flip";149  /** Swap transition duration in ms */150  duration?: number;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_WORDS = ["fast", "alive", "honest", "effortless", "yours"];168const DEFAULT_COLORS = ["#38bdf8", "#f472b6", "#fbbf24", "#34d399", "#a78bfa"];169 170export function ScrollWordSwap({171  prefix = "Interfaces that feel",172  words = DEFAULT_WORDS,173  suffix = ".",174  colors = DEFAULT_COLORS,175  effect = "slide",176  duration = 600,177  height = 380,178  borderRadius = 20,179  lerp = 0.1,180  wheelMultiplier = 1,181  smooth = true,182  className = "",183  style,184}: ScrollWordSwapProps) {185  const reduced = usePrefersReducedMotion();186  const viewportRef = React.useRef<HTMLDivElement>(null);187  const stageRef = React.useRef<HTMLDivElement>(null);188  const headlineRef = React.useRef<HTMLSpanElement>(null);189  const wordRefs = React.useRef<(HTMLSpanElement | null)[]>([]);190  const [active, setActive] = React.useState(0);191  const [widths, setWidths] = React.useState<number[] | null>(null);192  const list = words.length > 0 ? words : DEFAULT_WORDS;193  const tints = colors.length > 0 ? colors : DEFAULT_COLORS;194  const total = list.length;195 196  const handleScroll = React.useCallback(197    ({ progress }: SmoothScrollState) => {198      const t = progress * total;199      stageRef.current?.style.setProperty("--mf-sws-t", t.toFixed(4));200      setActive(clamp(Math.floor(t), 0, total - 1));201    },202    [total],203  );204 205  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });206 207  React.useEffect(() => {208    const headline = headlineRef.current;209    if (!headline) return;210    const measure = () => setWidths(wordRefs.current.slice(0, total).map((w) => (w ? Math.ceil(w.scrollWidth) : 0)));211    measure();212    const ro = new ResizeObserver(measure);213    ro.observe(headline);214    return () => ro.disconnect();215  }, [total, list.join("|")]);216 217  const tint = tints[active % tints.length];218  const slotWidth = widths && widths[active] ? `${widths[active]}px` : `${Math.max(2, list[active].length * 0.56)}em`;219 220  const vars = {221    "--mf-sws-h": `${height}px`,222    "--mf-sws-r": `${borderRadius}px`,223    "--mf-sws-dur": `${Math.max(0, duration)}ms`,224    "--mf-sws-tint": tint,225    ...style,226  } as React.CSSProperties;227 228  return (229    <div style={vars} className={`${styles.root} ${className}`.trim()} data-effect={effect}>230      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={`${prefix} ${list.join(", ")}${suffix}`}>231        <div style={{ height: height + total * Math.round(height * 0.7) }}>232          <div ref={stageRef} className={styles.stage}>233            {list.map((word, i) => (234              <span235                key={`${word}-bg-${i}`}236                aria-hidden="true"237                className={styles.wash}238                data-active={i === active ? "" : undefined}239                style={{ "--mf-sws-c": tints[i % tints.length] } as React.CSSProperties}240              />241            ))}242            <span className={styles.kicker}>{`${String(active + 1).padStart(2, "0")} / ${String(total).padStart(2, "0")}`}</span>243            <h3 className={styles.headline}>244              <span className={styles.srOnly}>{`${prefix} ${list.join(", ")}${suffix}`}</span>245              <span ref={headlineRef} aria-hidden="true" className={styles.visual}>246                {prefix}{" "}247                <span className={styles.slot} style={{ width: slotWidth }}>248                  {list.map((word, i) => (249                    <span250                      key={`${word}-${i}`}251                      ref={(node) => {252                        wordRefs.current[i] = node;253                      }}254                      className={styles.word}255                      data-state={i === active ? "active" : i < active ? "before" : "after"}256                      style={{ "--mf-sws-c": tints[i % tints.length] } as React.CSSProperties}257                    >258                      {word}259                    </span>260                  ))}261                </span>262                {suffix}263              </span>264            </h3>265            <div className={styles.progress} aria-hidden="true">266              {list.map((word, i) => (267                <span key={`${word}-p-${i}`} className={styles.segment} data-active={i === active ? "" : undefined}>268                  <span className={styles.track}>269                    <span className={styles.fill} style={{ "--mf-sws-i": i } as React.CSSProperties} />270                  </span>271                  <span className={styles.segmentLabel}>{word}</span>272                </span>273              ))}274            </div>275          </div>276        </div>277      </div>278    </div>279  );280}