Word reveal
Words rise one after another when the line scrolls in, or light up while you read. Screen readers hear the sentence once, without JavaScript it stands complete.
One of 5 components in Text effects
Details
What Word reveal brings along, read from the registry and the source.
- Category
- Text effects
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/word-reveal.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WordRevealProps interface from components/ui/word-reveal.tsx, with the comments from the code.
textrequiredstringThe full sentence. It is split at spaces, punctuation stays with its word.
asoptionalTagh1h2h3h4pspandiv
Element to render, keeps the heading level. Default p.
effectoptionalWordRevealEffectriseblurfade
rise: up out of a soft blur (default). blur: sharpens in place. fade: opacity only.
triggeroptionalWordRevealTriggerloadviewin-viewhoverscroll
load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: the words light up one after another while the line moves up through the window, as you read.
scrollRangeoptional[number, number]With trigger scroll: the part of the way through the window that lights the words, 0 to 1. Default [0.12, 0.62].
staggeroptionalnumberMilliseconds between two words. Default 70.
durationoptionalnumberMilliseconds one word takes. Default 800.
delayoptionalnumberMilliseconds before the first word. Default 0.
highlightoptionalstringA phrase inside text that takes the accent color, e.g. "verkaufen"
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 214 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
export type WordRevealEffect = 'rise' | 'blur' | 'fade';
/** in-view is the same as view */
export type WordRevealTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';
export interface WordRevealProps {
/** The full sentence. It is split at spaces, punctuation stays with its word. */
text: string;
/** Element to render, keeps the heading level. Default p. */
as?: Tag;
/** rise: up out of a soft blur (default). blur: sharpens in place. fade: opacity only. */
effect?: WordRevealEffect;
/**
* load: right away. view (or in-view): when it scrolls in (default).
* hover: again on every hover and focus. scroll: the words light up one
* after another while the line moves up through the window, as you read.
*/
trigger?: WordRevealTrigger;
/** With trigger scroll: the part of the way through the window that lights the words, 0 to 1. Default [0.12, 0.62]. */
scrollRange?: [number, number];
/** Milliseconds between two words. Default 70. */
stagger?: number;
/** Milliseconds one word takes. Default 800. */
duration?: number;
/** Milliseconds before the first word. Default 0. */
delay?: number;
/** A phrase inside text that takes the accent color, e.g. "verkaufen" */
highlight?: string;
/** Classes for the element, e.g. the type scale text-display, text-h1, text-h2, text-lead */
className?: string;
}
/*
* Keyframes and states live with the component, prefixed so they never collide.
* Scroll: every word reads its own share of --kitfx-p (written by the scroll
* link), a soft window two words wide, so the light runs through the
* sentence instead of switching word by word. Without the variable (server,
* no JavaScript, reduced motion) the share is 1 and every word stands lit.
*/
const CSS = `
.kitfx-wr-w{display:inline-block;white-space:nowrap;padding-block:.1em;margin-block:-.1em}
.kitfx-wr[data-mode=play] .kitfx-wr-w{animation:var(--kitfx-wr-name) var(--kitfx-wr-dur) cubic-bezier(.2,.7,.2,1) both;animation-delay:calc(var(--kitfx-wr-delay) + var(--i) * var(--kitfx-wr-stagger))}
.kitfx-wr[data-mode=wait] .kitfx-wr-w{opacity:0}
.kitfx-wr[data-mode=scroll] .kitfx-wr-w{--t:clamp(0,(var(--kitfx-p,1) * (var(--n) + 2) - var(--i)) / 2,1);opacity:calc(.16 + .84 * var(--t));transition:opacity .25s linear,transform .25s linear,filter .25s linear}
.kitfx-wr[data-mode=scroll][data-effect=rise] .kitfx-wr-w{transform:translateY(calc((1 - var(--t)) * .16em))}
.kitfx-wr[data-mode=scroll][data-effect=blur] .kitfx-wr-w{filter:blur(calc((1 - var(--t)) * 5px))}
.kitfx-wr[data-effect=rise]{--kitfx-wr-name:kitfx-wr-rise}
.kitfx-wr[data-effect=blur]{--kitfx-wr-name:kitfx-wr-blur}
.kitfx-wr[data-effect=fade]{--kitfx-wr-name:kitfx-wr-fade}
@keyframes kitfx-wr-rise{from{opacity:0;transform:translateY(.42em);filter:blur(6px)}}
@keyframes kitfx-wr-blur{from{opacity:0;filter:blur(14px)}}
@keyframes kitfx-wr-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){
.kitfx-wr[data-mode] .kitfx-wr-w{animation:kitfx-wr-fade .5s ease both;opacity:1;transform:none;filter:none}
}`;
interface Piece {
text: string;
accent: boolean;
start: number;
}
/** Splits text into words, and each word into plain and highlighted pieces. */
function split(text: string, highlight?: string) {
const at = highlight ? text.indexOf(highlight) : -1;
const end = at >= 0 ? at + highlight!.length : -1;
const words: Piece[][] = [];
for (const m of text.matchAll(/\S+/g)) {
const s = m.index ?? 0;
const w = m[0];
const cuts = [0, w.length];
if (at >= 0) {
if (at > s && at < s + w.length) cuts.push(at - s);
if (end > s && end < s + w.length) cuts.push(end - s);
}
const sorted = [...new Set(cuts)].sort((a, b) => a - b);
const pieces: Piece[] = [];
for (let i = 0; i < sorted.length - 1; i++) {
const a = s + sorted[i];
pieces.push({ text: w.slice(sorted[i], sorted[i + 1]), accent: at >= 0 && a >= at && a < end, start: a });
}
words.push(pieces);
}
return { words, at, length: at >= 0 ? highlight!.length : 0 };
}
/**
* One continuous accent gradient over a phrase that is split into pieces:
* every piece shows its slice of the same gradient (by character share).
*/
function accentSlice(start: number, len: number, phraseStart: number, phraseLen: number): React.CSSProperties {
if (len >= phraseLen) return {};
const p = (start - phraseStart) / (phraseLen - len);
return { backgroundSize: `${round((phraseLen / len) * 100)}% 100%`, backgroundPosition: `${round(p * 100)}% 0` };
}
/**
* Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
* travelled `start` of its way through the window, 1 at `end` (0 is its top
* at the bottom edge, 1 its bottom at the top edge). The value is scaled to
* the part of that way the page can still scroll, so a line at the end of a
* short page still finishes. Never with reduced motion, then the CSS
* fallback 1 keeps the final state.
*/
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number]) {
const [start, end] = range;
useEffect(() => {
const el = ref.current;
if (!el || !on || prefersReducedMotion()) return;
let frame = 0;
const span = Math.max(0.0001, end - start);
const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
const most = at(r.top - rest, r.height, vh);
const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
el.style.setProperty('--kitfx-p', p.toFixed(4));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
el.style.removeProperty('--kitfx-p');
};
}, [ref, on, start, end]);
}
/**
* Plays a CSS entrance. It is on from the first paint, so a line that is
* already on screen animates without waiting for JavaScript. A line further
* down is held back after hydration and plays when it scrolls in.
*/
function usePlay<T extends HTMLElement>(trigger: WordRevealTrigger) {
const ref = useRef<T>(null);
const [run, setRun] = useState(0);
const [mode, setMode] = useState<'play' | 'wait' | 'idle' | 'scroll'>(trigger === 'hover' ? 'idle' : trigger === 'scroll' ? 'scroll' : 'play');
useEffect(() => {
const el = ref.current;
if (!el || (trigger !== 'view' && trigger !== 'in-view') || prefersReducedMotion()) return;
if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
setMode('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setMode('play');
setRun((r) => r + 1);
io.disconnect();
},
{ rootMargin: '0px 0px -10% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, [trigger]);
const replay = useCallback(() => {
if (prefersReducedMotion()) return;
setMode('play');
setRun((r) => r + 1);
}, []);
return { ref, run, mode, replay };
}
/**
* Words rise one after another. Screen readers get the full sentence once,
* the animated words are hidden from them. Without JavaScript and with
* reduced motion the sentence stands complete. Words never break apart.
* With trigger="scroll" the words light up while you read.
* <WordReveal as="h1" className="text-display" text="Websites, die verkaufen" highlight="verkaufen" />
*/
export function WordReveal({ text, as: Tag = 'p', effect = 'rise', trigger = 'view', scrollRange = [0.12, 0.62], stagger = 70, duration = 800, delay = 0, highlight, className }: WordRevealProps) {
const { ref, run, mode, replay } = usePlay<HTMLElement>(trigger);
useScrollLink(ref, trigger === 'scroll', scrollRange);
const { words, at, length } = split(text.trim(), highlight);
const vars = { '--kitfx-wr-stagger': `${stagger}ms`, '--kitfx-wr-dur': `${duration}ms`, '--kitfx-wr-delay': `${delay}ms`, '--n': words.length } as React.CSSProperties;
const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};
return (
<Tag ref={ref as never} className={cn('kitfx-wr', className)} data-mode={mode} data-effect={effect} style={vars} {...hover}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<span className="sr-only">{text}</span>
<span aria-hidden key={run}>
{words.map((pieces, i) => (
<span key={i}>
{i > 0 && ' '}
<span className="kitfx-wr-w" style={{ '--i': i } as React.CSSProperties}>
{pieces.map((p) =>
p.accent ? (
<span key={p.start} className="kit-accent-text" style={accentSlice(p.start, p.text.length, at, length)}>
{p.text}
</span>
) : (
p.text
),
)}
</span>
</span>
))}
</span>
</Tag>
);
}Frequent questions
How do I install Word reveal?
With aiw add @ai-website/word-reveal. The command puts components/ui/word-reveal.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Word reveal have?
3 variants, Hero line, Blur and While reading.
Can an AI assistant build with Word reveal?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Word reveal with aiw_components and install it with aiw_add @ai-website/word-reveal.