Text reveal
Headline that animates in by word, character or line with rise, blur, fade, scale, drop or mask effects.
TextReveal, rise
Installation
npx shadcn add https://www.reelcn.dev/r/text-reveal.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<TextReveal text="Ship videos, not keyframes" effect="blur" accentWords={["videos"]} />
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.text*string
effectTextRevealEffect Default
"rise".split"word" | "char" | "line"What staggers in.
line splits on "\n". Default "word".staggernumberFrames between units. Defaults at 30fps: char 1, word 2, line 5.
sizenumberFont size in design units. Default
96.weightnumber
font"mono" | "heading" | "body" Default
"heading".colorstring
accentColorstring
accentWordsstring[]Words painted in the accent color. Case and punctuation are ignored. Default
[].align"left" | "right" | "center" Default
"center".from"left" | "right" | "below" | "above"Where
rise, blur and mask units travel in from. Default "below".orderStaggerOrderWhich unit enters first. Default
"forward".scaleFromnumberStarting scale for the
scale effect: below 1 grows in, above 1 slams down. Default 0.9.styleCSSProperties
classNamestring
Built from
Use
- Hero headlines, section titles and short-form hooks
- Any line of up to about twelve words that needs an entrance
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/text-reveal.tsx
/**
* @title Text Reveal
* @category text
* @description Headline that animates in by word, character or line with rise, blur, fade, scale, drop or mask effects.
* @duration 30
* @use Hero headlines, section titles and short-form hooks
* @use Any line of up to about twelve words that needs an entrance
* @tags headline, title, kinetic, stagger
* @example
* <Center>
* <TextReveal text="Ship videos, not keyframes" effect="blur" accentWords={["videos"]} />
* </Center>
*/
import type React from "react";
import {
alpha,
clamp01,
graphemes,
type MotionProps,
quantizeMotion,
type StaggerOrder,
staggerDelay,
tween,
useMotion,
useTheme,
useVariableFontAxis,
useViewport,
} from "./core";
export type TextRevealEffect =
| "rise"
| "blur"
| "fade"
| "scale"
| "drop"
| "mask"
| "track"
| "outline-fill"
| "split-flap"
| "variable-axis";
export type TextRevealProps = MotionProps & {
text: string;
effect?: TextRevealEffect;
/** What staggers in. `line` splits on "\n". */
split?: "word" | "char" | "line";
/** Frames between units. Defaults at 30fps: char 1, word 2, line 5. */
stagger?: number;
/** Font size in design units. */
size?: number;
weight?: number;
font?: "heading" | "body" | "mono";
color?: string;
accentColor?: string;
/** Words painted in the accent color. Case and punctuation are ignored. */
accentWords?: string[];
align?: "left" | "center" | "right";
/** Where `rise`, `blur` and `mask` units travel in from. */
from?: "below" | "above" | "left" | "right";
/** Which unit enters first. */
order?: StaggerOrder;
/** Starting scale for the `scale` effect: below 1 grows in, above 1 slams down. */
scaleFrom?: number;
style?: React.CSSProperties;
className?: string;
};
const normalize = (word: string) => word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
const SPLIT_FLAP_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,!?-".split("");
/** Flips a character forward through a small fixed set of intermediate glyphs into `target` at a constant
* rate (`t` is a linear 0-1 clock, like a mechanical board), with a per-unit flip count seeded from the
* unit's own index (deterministic, not `Math.random`), an airport-departures-board feel. */
function splitFlapChar(target: string, t: number, seed: number): string {
if (t >= 1) return target;
const targetIndex = SPLIT_FLAP_GLYPHS.indexOf(target.toUpperCase());
if (targetIndex < 0) return target;
const n = SPLIT_FLAP_GLYPHS.length;
const remaining = Math.ceil((1 - t) * (6 + (seed % 3)));
return SPLIT_FLAP_GLYPHS[(targetIndex - remaining + n) % n];
}
const DIRECTIONS = { below: [0, 1], above: [0, -1], left: [-1, 0], right: [1, 0] } as const;
type UnitOptions = { from: keyof typeof DIRECTIONS; scaleFrom: number; chars?: number };
function unitStyle(
effect: TextRevealEffect,
progress: number,
fontPx: number,
{ from, scaleFrom, chars = 0 }: UnitOptions,
): React.CSSProperties {
const opacity = Math.min(Math.max(progress, 0), 1);
const hidden = 1 - progress;
const toward = (amount: number, unit: string) =>
`${DIRECTIONS[from][0] * amount}${unit} ${DIRECTIONS[from][1] * amount}${unit}`;
const blur = (strength: number) => `blur(${Math.max(hidden, 0) * fontPx * strength}px)`;
switch (effect) {
case "rise":
return {
opacity,
translate: toward(hidden * 0.45, "em"),
scale: String(0.96 + 0.04 * progress),
filter: blur(0.04),
};
case "blur":
return { opacity, filter: blur(0.12), translate: toward(hidden * 0.15, "em") };
case "fade":
return { opacity };
case "scale":
return { opacity, scale: String(scaleFrom + (1 - scaleFrom) * progress) };
case "drop":
return { opacity, translate: `0 ${-hidden * 0.6}em`, rotate: `${-hidden * 8}deg` };
case "mask":
// Overshoot clamped: pushing past 0 inside the clip would crop ascenders.
return { translate: toward(Math.max(hidden, 0) * 110, "%"), filter: blur(0.05) };
case "track": {
// Negative inline margins cancel the added spacing, so the unit's layout width stays constant and
// neighbors never shove sideways or re-wrap mid-stagger.
const spacing = hidden * 0.5;
return {
opacity,
letterSpacing: `${spacing}em`,
marginInline: `${(-spacing * chars) / 2}em`,
filter: blur(0.08),
};
}
case "outline-fill": {
// `color: transparent` also zeroed out `currentColor` for the stroke on this same element (it's
// the same property `currentColor` resolves against), so the stroke was invisible for the entire
// outline phase and the fill then snapped in at 0.92 with nothing having been visible before it.
// `-webkit-text-fill-color` controls the glyph fill paint without touching `color`, so `currentColor`
// stays the real, opaque color for the stroke throughout. `progress` here is a linear clock (the
// caller passes it instead of the eased preset, whose shape varies by theme: `settle` left a single
// outline frame), so thresholds map straight to time: opacity ramps in over the first 15%, a
// stroke-only outline holds through 55%, and the fill phases in, stroke shrinking as it does, over
// the remaining 45%.
const p = Math.min(Math.max(progress, 0), 1);
const strokeIn = Math.min(1, p / 0.15);
const fillProgress = Math.max(0, (p - 0.55) / 0.45);
return {
opacity: strokeIn,
WebkitTextFillColor: alpha("currentColor", fillProgress),
WebkitTextStroke: `${(1 - fillProgress) * strokeIn * fontPx * 0.05}px currentColor`,
};
}
case "split-flap":
return { opacity: 1 };
case "variable-axis":
return { opacity, fontVariationSettings: `"wdth" ${(140 + (100 - 140) * progress).toFixed(2)}` };
}
}
// Scripts written without spaces between words split into words here; scripts whose letters join stay whole
// under `split="char"`, since a letter on its own inline-block loses its joined form.
const NO_SPACES = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]/u;
const JOINED = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Nko}\p{Script=Mongolian}]/u;
function unitsOf(word: string, split: "word" | "char" | "line"): string[] {
if (split === "char" && !JOINED.test(word)) return graphemes(word);
if (!NO_SPACES.test(word)) return [word];
return [...new Intl.Segmenter("en", { granularity: "word" }).segment(word)].map((part) => part.segment);
}
export function TextReveal({
text,
effect = "rise",
split = "word",
stagger,
size = 96,
weight,
font = "heading",
color,
accentColor,
accentWords = [],
align = "center",
from = "below",
order = "forward",
scaleFrom = 0.9,
style,
className,
...motion
}: TextRevealProps) {
const theme = useTheme();
const { u, width, safe } = useViewport();
const m = useMotion(motion);
// variable-axis falls back to fade on every theme but mono, the only one that loads the Archivo variable cut
// this effect animates.
const effectiveEffect = effect === "variable-axis" && theme.name !== "mono" ? "fade" : effect;
// mono's signature move: Archivo's wdth axis snaps from expanded to condensed as the
// headline enters. Always computed (cheap, pure math) so this hook is never called conditionally;
// only applied to the style below when the theme and font slot actually use the variable cut.
const axisSettings = useVariableFontAxis(125, 62, {
fps: m.fps,
frame: m.frame,
motion: motion.motion ?? theme.motion,
delay: m.delay,
duration: m.enterFrames,
});
const fontPx = u(size);
const step =
stagger ??
(split === "char" ? Math.max(1, Math.round(m.fps * 0.035)) : Math.round(m.fps * (split === "word" ? 0.07 : 0.1)));
const accents = new Set(accentWords.map(normalize));
// The theme's on-twos step/jitter, so units stay in cadence with the rest of the scene.
const q = quantizeMotion(motion.motion ?? theme.motion);
// outline-fill and split-flap read time, not the eased (possibly overshooting) preset.
// `poster` renders the finished state: every unit fully in, whatever the frame.
const linearAt = (start: number) => (motion.poster ? 1 : clamp01((m.frame - start) / Math.max(m.enterFrames, 1)));
const count =
split === "line"
? text.split("\n").length
: text
.split(/\s+/)
.filter(Boolean)
.reduce((n, word) => n + unitsOf(word, split).length, 0);
const startOf = (index: number) => m.delay + staggerDelay(index, count, { step, order, seed: text });
const unitOptions = { from, scaleFrom };
let unitIndex = 0;
const renderUnit = (content: string, key: number, index: number) => {
const start = startOf(index);
const progress = motion.poster
? 1
: tween(m.frame, m.fps, {
from: start,
duration: m.enterFrames,
motion: m.preset,
step: q.step,
jitter: q.jitter,
});
const linear = linearAt(start);
const displayContent = effectiveEffect === "split-flap" ? splitFlapChar(content, linear, index) : content;
const shown = effectiveEffect === "outline-fill" ? linear : progress;
const inner = (
<span
key={key}
style={{
display: "inline-block",
whiteSpace: "pre",
...unitStyle(effectiveEffect, shown, fontPx, { ...unitOptions, chars: graphemes(content).length }),
}}
>
{displayContent}
</span>
);
if (effect !== "mask") return inner;
// A mask needs a clipping wrapper; the padding keeps descenders from being cut.
return (
<span
key={key}
style={{
display: "inline-block",
overflow: "hidden",
verticalAlign: "top",
paddingBottom: "0.12em",
marginBottom: "-0.12em",
}}
>
{inner}
</span>
);
};
const lines = text.split("\n").map((line, lineNumber) => {
const lineIndex = unitIndex;
if (split === "line") unitIndex++;
const parts = line.split(/(\s+)/).map((word, wordNumber) => {
// Whitespace stays a plain text node so the browser can still wrap the line.
if (!word || /^\s+$/.test(word)) return word;
const accent = accents.has(normalize(word)) ? { color: accentColor ?? theme.colors.accent } : undefined;
if (split === "char") {
return (
<span key={wordNumber} style={{ display: "inline-block", whiteSpace: "nowrap", ...accent }}>
{unitsOf(word, "char").map((char, charNumber) => renderUnit(char, charNumber, unitIndex++))}
</span>
);
}
// `line` stagger animates the whole line as one rigid unit below, so words here stay plain.
if (split === "line")
return accent ? (
<span key={wordNumber} style={accent}>
{word}
</span>
) : (
word
);
return (
<span key={wordNumber} style={accent}>
{unitsOf(word, "word").map((unit, unitNumber) => renderUnit(unit, unitNumber, unitIndex++))}
</span>
);
});
if (split !== "line") return <div key={lineNumber}>{parts}</div>;
const lineStart = startOf(lineIndex);
const progress = motion.poster
? 1
: tween(m.frame, m.fps, {
from: lineStart,
duration: m.enterFrames,
motion: m.preset,
step: q.step,
jitter: q.jitter,
});
const shown = effectiveEffect === "outline-fill" ? linearAt(lineStart) : progress;
return (
<div
key={lineNumber}
style={effect === "mask" ? { overflow: "hidden", paddingBottom: "0.12em", marginBottom: "-0.12em" } : undefined}
>
<span
style={{
display: "inline-block",
...unitStyle(effectiveEffect, shown, fontPx, unitOptions),
}}
>
{parts}
</span>
</div>
);
});
return (
<div
className={className}
style={{
fontFamily: theme.fonts[font],
fontSize: fontPx,
fontWeight: weight ?? (font === "heading" ? theme.headingWeight : 500),
color: color ?? theme.colors.foreground,
lineHeight: 1.1,
letterSpacing: font === "mono" ? 0 : "-0.025em",
fontVariationSettings: theme.name === "mono" && font === "heading" ? axisSettings : undefined,
textAlign: align,
textWrap: "balance",
maxWidth: width - safe.x * 2,
opacity: 1 - m.exit,
translate: `0 ${-m.exit * fontPx * 0.2}px`,
...style,
}}
>
{lines}
</div>
);
}