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
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-word-swapWrites 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.
1import { ScrollWordSwap } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollWordSwap />6 );7}Props
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
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}