Text Reveal on Scroll

A manifesto whose words light up as you scroll, with keywords that get a hand-drawn marker underline.

  • Scroll & Hover Effects
  • Climate
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Only opacity and transform animate.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/text-reveal-scroll.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/text-reveal-scroll.tsx
1/**
2 * ScrollTextReveal — a manifesto paragraph whose words light up one by one
3 * (from a dim floor to full opacity) as the reader scrolls. Keywords wrapped
4 * in *asterisks* get an accent colour and a hand-drawn marker underline that
5 * draws on when the reveal reaches them.
6 *
7 * Props:
8 * - text: string. Wrap keywords as *like this*. Paragraph breaks: "\n\n".
9 * - start / end: reveal window as fractions of the viewport (where the block
10 * top/bottom meet the viewport). Default: starts when the block's top hits
11 * 85% of the viewport, completes when its bottom reaches 55%.
12 * - floor: starting opacity of unrevealed words (default 0.15).
13 * - scrollContainer: optional ref; otherwise nearest scrollable ancestor.
14 * - as: element for each paragraph (default "p").
15 * CSS vars: --fd-trs-ink, --fd-trs-accent, --fd-trs-marker.
16 *
17 * Browser support: evergreen. Only opacity and transform are animated.
18 * Accessibility: the full text is in the DOM and readable by assistive tech
19 * at all times; words are aria-hidden spans with a visually hidden copy.
20 * Reduced motion: shows the finished state.
21 */
22import { Fragment, useEffect, useMemo, useRef } from "react";
23import type { CSSProperties, ElementType, RefObject } from "react";
24
25export interface ScrollTextRevealProps {
26 text: string;
27 start?: number;
28 end?: number;
29 floor?: number;
30 scrollContainer?: RefObject<HTMLElement | null>;
31 as?: ElementType;
32 className?: string;
33 style?: CSSProperties;
34}
35
36type Word = { w: string; key: boolean; i: number };
37
38const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
39
40function findScrollParent(el: HTMLElement): HTMLElement | null {
41 let p = el.parentElement;
42 while (p && p !== document.body && p !== document.documentElement) {
43 const oy = getComputedStyle(p).overflowY;
44 if ((oy === "auto" || oy === "scroll" || oy === "overlay") && p.scrollHeight > p.clientHeight + 1) return p;
45 p = p.parentElement;
46 }
47 return null;
48}
49
50export function ScrollTextReveal({
51 text,
52 start = 0.85,
53 end = 0.55,
54 floor = 0.15,
55 scrollContainer,
56 as: Tag = "p",
57 className,
58 style,
59}: ScrollTextRevealProps) {
60 const root = useRef<HTMLDivElement>(null);
61
62 const paras = useMemo(() => {
63 let i = 0;
64 return text.split(/\n\s*\n/).map((para) => {
65 const out: Word[] = [];
66 // split into keyword groups (*...*) and plain runs
67 para.split(/(\*[^*]+\*)/).forEach((chunk) => {
68 if (!chunk) return;
69 const key = chunk.startsWith("*") && chunk.endsWith("*");
70 const body = key ? chunk.slice(1, -1) : chunk;
71 body
72 .split(/\s+/)
73 .filter(Boolean)
74 .forEach((w) => out.push({ w, key, i: i++ }));
75 });
76 return out;
77 });
78 }, [text]);
79 const total = paras.reduce((n, p) => n + p.length, 0);
80 const plain = text.replace(/\*/g, "");
81
82 useEffect(() => {
83 const el = root.current;
84 if (!el) return;
85 const words = Array.from(el.querySelectorAll<HTMLElement>(".fd-trs-w"));
86 const marks = Array.from(el.querySelectorAll<HTMLElement>(".fd-trs-mark"));
87 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
88 el.dataset.done = "";
89 return;
90 }
91 const sc = scrollContainer?.current ?? findScrollParent(el);
92 const target: HTMLElement | Window = sc ?? window;
93 let raf = 0;
94 let visible = true;
95 let shown = -1;
96 let goal = 0;
97 let lastT = 0;
98 const lastOp = new Float32Array(words.length).fill(-1);
99 const markAt = marks.map((m) => Number(m.dataset.at));
100
101 const measure = () => {
102 const vh = sc ? sc.clientHeight : window.innerHeight;
103 const r = el.getBoundingClientRect();
104 const top = sc ? r.top - sc.getBoundingClientRect().top : r.top;
105 const a = vh * start; // top at this line → 0
106 const b = vh * end; // bottom at this line → 1
107 const span = a - b + r.height;
108 return clamp((a - top) / (span || 1));
109 };
110
111 const render = (p: number) => {
112 // a soft "pen" of ~6 words wide travels through the text
113 const head = p * (total + 6) - 3;
114 for (let i = 0; i < words.length; i++) {
115 const t = clamp((head - i) / 6 + 0.5);
116 const o = Math.round((floor + (1 - floor) * t) * 100) / 100;
117 if (o !== lastOp[i]) {
118 lastOp[i] = o;
119 words[i].style.opacity = String(o);
120 }
121 }
122 for (let k = 0; k < marks.length; k++) {
123 const t = clamp((head - markAt[k] - 0.5) / 1.6);
124 marks[k].style.transform = `scaleX(${t.toFixed(3)})`;
125 }
126 };
127
128 const tick = (t: number) => {
129 const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016);
130 lastT = t;
131 goal = measure();
132 if (shown < 0) shown = goal;
133 shown += (goal - shown) * (1 - Math.exp(-dt * 12));
134 if (Math.abs(goal - shown) < 0.0005) shown = goal;
135 render(shown);
136 raf = shown !== goal ? requestAnimationFrame(tick) : 0;
137 };
138 const kick = () => {
139 if (!raf && visible) {
140 lastT = performance.now();
141 raf = requestAnimationFrame(tick);
142 }
143 };
144 shown = measure();
145 render(shown);
146 const io = new IntersectionObserver(
147 ([e]) => {
148 visible = e.isIntersecting;
149 if (visible) kick();
150 },
151 { root: sc ?? null, rootMargin: "100px 0px" },
152 );
153 io.observe(el);
154 target.addEventListener("scroll", kick, { passive: true });
155 window.addEventListener("resize", kick);
156 return () => {
157 io.disconnect();
158 target.removeEventListener("scroll", kick);
159 window.removeEventListener("resize", kick);
160 if (raf) cancelAnimationFrame(raf);
161 };
162 }, [scrollContainer, start, end, floor, total]);
163
164 return (
165 <div ref={root} className={`fd-trs${className ? " " + className : ""}`} style={{ ["--fd-trs-floor" as string]: floor, ...style }}>
166 <style>{css}</style>
167 <span className="fd-trs-sr">{plain}</span>
168 {paras.map((words, pi) => {
169 // group consecutive keyword words so one marker spans the phrase
170 const groups: Word[][] = [];
171 words.forEach((w) => {
172 const g = groups[groups.length - 1];
173 if (g && g[0].key && w.key) g.push(w);
174 else groups.push([w]);
175 });
176 return (
177 <Tag key={pi} className="fd-trs-p" aria-hidden="true">
178 {groups.map((g, gi) =>
179 g[0].key ? (
180 <Fragment key={gi}>
181 {g.map((w, k) => (
182 <Fragment key={w.i}>
183 <span className="fd-trs-key" data-last={k === g.length - 1 ? "" : undefined}>
184 <span className="fd-trs-w">{w.w}</span>
185 <span className="fd-trs-mark" data-at={w.i}>
186 <svg viewBox="0 0 100 14" preserveAspectRatio="none" aria-hidden="true">
187 <path d={k === 0 ? MARK_HEAD : k === g.length - 1 ? MARK_TAIL : MARK_MID} />
188 </svg>
189 </span>
190 </span>{" "}
191 </Fragment>
192 ))}
193 </Fragment>
194 ) : (
195 <Fragment key={gi}>
196 <span className="fd-trs-w">{g[0].w}</span>{" "}
197 </Fragment>
198 ),
199 )}
200 </Tag>
201 );
202 })}
203 </div>
204 );
205}
206
207// marker strokes: rounded head, flat middle, ragged tail
208const MARK_HEAD = "M6 5 C 30 3 70 5 100 4 L 100 12 C 70 12 30 13 6 13 C 1 12 0 6 6 5 Z";
209const MARK_MID = "M0 4 C 30 5 70 3 100 4 L 100 12 C 70 13 30 11 0 12 Z";
210const MARK_TAIL = "M0 4 C 30 3 70 5 92 4 C 99 5 100 9 96 12 C 70 13 30 12 0 12 Z";
211
212const css = `
213.fd-trs{
214 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-dur-1:120ms;
215 --fd-trs-ink:#0F1A14;--fd-trs-accent:#0F7B4A;--fd-trs-marker:#C8F169;
216 position:relative;display:block;margin:0;padding:0;color:var(--fd-trs-ink);
217}
218.fd-trs,.fd-trs *{box-sizing:border-box}
219.fd-trs-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
220.fd-trs-p{
221 margin:0 0 .6em;padding:0;color:inherit;
222 font:600 clamp(1.75rem,4.2vw,3.5rem)/1.12 "SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter Display","Inter","Segoe UI",Roboto,sans-serif;
223 letter-spacing:-0.03em;text-wrap:pretty;
224}
225.fd-trs-p:last-child{margin-bottom:0}
226.fd-trs-w{opacity:var(--fd-trs-floor);will-change:opacity}
227.fd-trs-key{position:relative;display:inline-block;color:var(--fd-trs-accent);isolation:isolate}
228.fd-trs-mark{
229 position:absolute;left:-.06em;right:-.3em;bottom:-.02em;height:.5em;z-index:-1;display:block;
230 transform:scaleX(0);transform-origin:0 50%;will-change:transform;
231}
232.fd-trs-key[data-last] .fd-trs-mark{right:-.08em}
233.fd-trs-mark svg{display:block;width:100%;height:100%;overflow:visible}
234.fd-trs-mark path{fill:var(--fd-trs-marker)}
235.fd-trs[data-done] .fd-trs-w{opacity:1}
236.fd-trs[data-done] .fd-trs-mark{transform:none}
237`;
238
239/* ------------------------------------------------------------------ Demo */
240
241const MANIFESTO = `We don’t fund promises. We fund the boring, physical work of pulling carbon out of the air — kilns, pipes, rock, soil — and we pay for it by the *verified tonne.*
242
243Since 2021 we’ve backed 46 teams across nine countries. Every tonne is measured twice, priced honestly and retired in public. Our job is to make permanence *cheaper than pollution.*`;
244
245export function Demo() {
246 return (
247 <div className="fd-trs-demo">
248 <style>{demoCss}</style>
249 <section className="fd-trs-demo-hero">
250 <div className="fd-trs-demo-top">
251 <span className="fd-trs-demo-logo">
252 <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
253 <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
254 <path d="M12 4 C 7 9 7 15 12 20 C 17 15 17 9 12 4 Z" fill="currentColor" />
255 </svg>
256 Tidewater Climate Fund
257 </span>
258 <span className="fd-trs-demo-tag">Fund III · $410M</span>
259 </div>
260 <h1 className="fd-trs-demo-h">Our manifesto</h1>
261 <p className="fd-trs-demo-cue" aria-hidden="true">
262 <span className="fd-trs-demo-cue-line" />
263 Scroll to read
264 </p>
265 </section>
266 <section className="fd-trs-demo-body">
267 <span className="fd-trs-demo-label">01 — Why we exist</span>
268 <ScrollTextReveal text={MANIFESTO} />
269 </section>
270 <section className="fd-trs-demo-stats">
271 {[
272 ["2.1M t", "CO₂ removal contracted"],
273 ["46", "portfolio companies"],
274 ["1,000+ yrs", "minimum storage we fund"],
275 ].map(([v, l]) => (
276 <div key={l} className="fd-trs-demo-stat">
277 <span className="fd-trs-demo-stat-v">{v}</span>
278 <span className="fd-trs-demo-stat-l">{l}</span>
279 </div>
280 ))}
281 </section>
282 </div>
283 );
284}
285
286const demoCss = `
287.fd-trs-demo{display:block;margin:0;padding:0;background:#F3F1EA;color:#0F1A14;min-height:100vh;font-family:ui-sans-serif,-apple-system,"Inter","Segoe UI",sans-serif}
288.fd-trs-demo,.fd-trs-demo *{box-sizing:border-box}
289.fd-trs-demo-hero{display:flex;flex-direction:column;min-height:58vh;max-width:1200px;margin:0 auto;padding:28px clamp(16px,5vw,64px) 40px}
290.fd-trs-demo-top{display:flex;align-items:center;justify-content:space-between;gap:16px}
291.fd-trs-demo-logo{display:inline-flex;align-items:center;gap:10px;color:#0F1A14;font:600 0.9375rem/1 ui-sans-serif,-apple-system,"Inter",sans-serif;letter-spacing:-0.01em}
292.fd-trs-demo-tag{padding:7px 12px;border-radius:9999px;border:1px solid rgba(15,26,20,.14);color:#4A5A50;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace}
293.fd-trs-demo-h{margin:auto 0 0;padding:0;color:#0F1A14;font:650 clamp(3.5rem,13vw,10rem)/.88 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.055em}
294.fd-trs-demo-cue{display:inline-flex;align-items:center;gap:10px;margin:28px 0 0;padding:0;color:#4A5A50;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}
295.fd-trs-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(15,26,20,.15)}
296.fd-trs-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#0F1A14;animation:fd-trs-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}
297@keyframes fd-trs-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}
298.fd-trs-demo-body{display:grid;grid-template-columns:180px minmax(0,1fr);gap:24px;max-width:1200px;margin:0 auto;padding:6vh clamp(16px,5vw,64px) 40vh}
299.fd-trs-demo-label{padding-top:.9em;color:#4A5A50;font:500 0.75rem/1.3 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.04em}
300.fd-trs-demo-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;max-width:1200px;margin:0 auto;padding:40px clamp(16px,5vw,64px) 120px;border-top:1px solid rgba(15,26,20,.12)}
301.fd-trs-demo-stat{display:flex;flex-direction:column;gap:8px}
302.fd-trs-demo-stat-v{color:#0F1A14;font:600 clamp(2rem,4vw,3rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.035em}
303.fd-trs-demo-stat-l{color:#4A5A50;font:400 0.9375rem/1.4 ui-sans-serif,-apple-system,"Inter",sans-serif}
304@media (max-width:720px){
305 .fd-trs-demo-body{grid-template-columns:1fr;gap:12px;padding-top:6vh}
306 .fd-trs-demo-label{padding-top:0}
307 .fd-trs-demo-stats{grid-template-columns:1fr;gap:20px}
308 .fd-trs-demo-tag{display:none}
309}
310@media (prefers-reduced-motion: reduce){.fd-trs-demo-cue-line::after{animation:none}}
311`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.