Scroll word reveal
A paragraph starts faint and each word fills in to full ink as it scrolls past the middle of the view.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const TEXT =
"Casa Mar sits above a quiet cove twenty minutes from Ericeira. Mornings start with coffee on the terrace and the sound of the surf. The pool is heated all season, the kitchen is stocked for your first breakfast, and Ana, your host, is one message away when you want a table, a boat or a surf lesson.";
const WORDS = TEXT.split(" ");
export default function Demo() {
const scroller = useRef<HTMLDivElement>(null);
const words = useRef<(HTMLSpanElement | null)[]>([]);
const reduced = usePrefersReducedMotion();
useEffect(() => {
const el = scroller.current;
if (!el) return;
if (reduced) {
words.current.forEach((w) => w && (w.style.opacity = "1"));
return;
}
let centres: number[] = [];
let raf = 0;
const update = () => {
raf = 0;
const middle = el.scrollTop + el.clientHeight / 2;
const band = el.clientHeight * 0.16;
words.current.forEach((w, i) => {
if (!w) return;
const t = Math.min(1, Math.max(0, (middle - centres[i]) / band + 0.5));
w.style.opacity = String(0.16 + 0.84 * t);
});
};
const measure = () => {
centres = words.current.map((w) => (w ? w.offsetTop + w.offsetHeight / 2 : 0));
update();
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
measure();
el.addEventListener("scroll", onScroll, { passive: true });
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => {
cancelAnimationFrame(raf);
el.removeEventListener("scroll", onScroll);
observer.disconnect();
};
}, [reduced]);
return (
<div className={styles.root}>
<div ref={scroller} className={styles.scroller} tabIndex={0} role="region" aria-label="About Casa Mar">
<div className={styles.inner}>
<p className={styles.eyebrow}>Casa Mar, Ericeira</p>
<p className={styles.text}>
<span className={styles.sr}>{TEXT}</span>
<span aria-hidden="true">
{WORDS.map((word, i) => (
<span key={i}>
<span ref={(el) => { words.current[i] = el; }} className={styles.word}>{word}</span>{" "}
</span>
))}
</span>
</p>
</div>
</div>
<p className={styles.hint} aria-hidden="true">Scroll the text</p>
</div>
);
}
demo.module.css
.root { container-type: size; position: relative; width: 100%; height: 100%; }
.scroller {
position: relative; height: 100%; overflow-y: auto; scrollbar-width: thin;
padding: 26cqh clamp(20px, 8cqi, 96px) 52cqh;
mask-image: linear-gradient(transparent, var(--ink) 14%, var(--ink) 72%, transparent 94%);
}
.scroller:focus-visible { outline-offset: -4px; }
.inner { max-width: 22em; margin: 0 auto; font-size: clamp(20px, min(5.4cqi, 9cqh), 36px); }
.eyebrow { margin-bottom: 10px; font-size: 13px; font-weight: 600; color: var(--accent-text); }
.text { line-height: 1.35; letter-spacing: -0.02em; font-weight: 600; text-wrap: auto; }
.word { opacity: 0.16; }
.hint { position: absolute; left: 50%; bottom: 10px; translate: -50% 0; margin: 0; padding: 4px 12px; border-radius: 999px; background: var(--white); border: 1px solid var(--line); font-size: 12px; font-weight: 600; color: var(--muted); pointer-events: none; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .word { opacity: 1; } }
Use it for
- One short statement or story section that should be read slowly
- Long scrolling brand pages, as a pacing device between sections
Avoid
- Anything longer than a short paragraph, readers have to scroll to read
- Product UI and help content, where text must be readable at once
How it works
Every word is a span. On mount and on resize the centre of each word is read from offsetTop inside the scroll container. A passive scroll listener schedules one requestAnimationFrame that compares each centre to scrollTop plus half the container height and maps that distance over a band of 16 percent of the height to opacity between 0.16 and 1. Only opacity is written, so it never causes layout.
Accessibility
A visually hidden copy of the paragraph is read once and the word spans are aria-hidden. The scroll region is focusable and labelled, so arrow keys and Page Down work. Reduced motion shows every word at full ink.