Animated Text
Staggered text reveal for headings, pull quotes, and short supporting copy. Install Animated Text from the VLLNT UI registry with the shadcn CLI.
Staggered text reveal for headings, pull quotes, and short supporting copy. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Animated Text to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/animated-text.jsonSource
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
const GLYPH_SEGMENTER = new Intl.Segmenter(undefined, {
granularity: "grapheme",
});
const ASCII_RANDOM_CHARACTERS = Array.from({ length: 94 }, (_, index) =>
String.fromCodePoint(index + 33),
).join("");
const TERMINAL_RANDOM_CHARACTERS = "│┃─━┄┅┈┉┌┐└┘├┤┬┴┼╭╮╯╰╱╲╳";
const BLOCK_RANDOM_CHARACTERS = "░▒▓█▌▐▀▄■□▪▫▖▗▘▙▚▛▜▝▞▟";
const UNICODE_SYMBOL_RANDOM_CHARACTERS = "◆◇◈○●◎◉◌◍◐◑◒◓◔◕◢◣◤◥◦※✦✧✱✶✷✹";
const MATRIX_RANDOM_CHARACTERS = `${ASCII_RANDOM_CHARACTERS}${TERMINAL_RANDOM_CHARACTERS}${BLOCK_RANDOM_CHARACTERS}${UNICODE_SYMBOL_RANDOM_CHARACTERS}`;
export const ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS = {
ascii: ASCII_RANDOM_CHARACTERS,
binary: "01",
blocks: BLOCK_RANDOM_CHARACTERS,
matrix: MATRIX_RANDOM_CHARACTERS,
symbols: UNICODE_SYMBOL_RANDOM_CHARACTERS,
terminal: TERMINAL_RANDOM_CHARACTERS,
} as const;
const DEFAULT_RANDOM_CHARACTERS = ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS.matrix;
type AnimatedTextSplit = "character" | "word";
export type AnimatedTextVariant =
| "decipher"
| "matrix"
| "reveal"
| "terminal"
| "typewriter";
export type AnimatedTextDirection = "center-out" | "end" | "random" | "start";
export type AnimatedTextRandomCharacterPreset =
keyof typeof ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS;
type SegmentFrame = {
content: string;
isRevealed: boolean;
key: string;
style?: React.CSSProperties;
};
export type AnimatedTextProps = React.ComponentPropsWithoutRef<"p"> & {
cursor?: boolean;
cursorChar?: string;
direction?: AnimatedTextDirection;
duration?: number;
randomCharacters?: string;
randomCharactersPreset?: AnimatedTextRandomCharacterPreset;
randomness?: number;
splitBy?: AnimatedTextSplit;
stagger?: number;
text: string;
variant?: AnimatedTextVariant;
};
function getSegments(text: string, splitBy: AnimatedTextSplit): string[] {
if (splitBy === "character") {
return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);
}
return text.match(/\S+\s*/g) ?? [];
}
function getGlyphs(text: string): string[] {
return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);
}
function getRandomMatrixGlyph(randomCharacters: string): string {
const glyphs = getGlyphs(randomCharacters);
return glyphs[Math.floor(Math.random() * glyphs.length)] ?? glyphs[0] ?? "0";
}
function getResolvedRandomCharacters(
randomCharacters: string | undefined,
randomCharactersPreset: AnimatedTextRandomCharacterPreset,
): string {
if (randomCharacters && randomCharacters.length > 0) {
return randomCharacters;
}
return (
ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS[randomCharactersPreset] ??
DEFAULT_RANDOM_CHARACTERS
);
}
function getCursorToneClass(variant: AnimatedTextVariant): string {
return variant === "matrix" || variant === "decipher"
? "text-primary"
: "text-foreground";
}
function buildRevealFrames(
segments: string[],
stagger: number,
): SegmentFrame[] {
return segments.map((segment, index) => ({
content: segment,
isRevealed: true,
key: `${segment}-${index}`,
style: {
animationDelay: `${index * stagger}ms`,
},
}));
}
function buildIndexOrder(
direction: AnimatedTextDirection,
length: number,
): number[] {
if (direction === "end") {
return Array.from({ length }, (_, index) => length - index - 1);
}
if (direction === "random") {
return Array.from({ length }, (_, index) => index).sort(
() => Math.random() - 0.5,
);
}
if (direction === "center-out") {
const center = (length - 1) / 2;
return Array.from({ length }, (_, index) => index).sort((left, right) => {
const leftDistance = Math.abs(left - center);
const rightDistance = Math.abs(right - center);
if (leftDistance === rightDistance) {
return left - right;
}
return leftDistance - rightDistance;
});
}
return Array.from({ length }, (_, index) => index);
}
function buildRevealPlan(
direction: AnimatedTextDirection,
length: number,
randomness: number,
): number[] {
const orderedIndices = buildIndexOrder(direction, length);
const revealPlan = Array.from({ length }, () => 0);
const jitterRange = Math.max(0, Math.round(randomness * 4));
orderedIndices.forEach((segmentIndex, revealIndex) => {
const jitter =
jitterRange > 0 ? Math.floor(Math.random() * (jitterRange + 1)) : 0;
revealPlan[segmentIndex] = revealIndex + jitter;
});
return revealPlan;
}
function useRevealProgress(active: boolean, length: number, stagger: number) {
const [progress, setProgress] = React.useState(() => (active ? 0 : length));
const [revealKey, setRevealKey] = React.useState({ active, length, stagger });
if (
revealKey.active !== active ||
revealKey.length !== length ||
revealKey.stagger !== stagger
) {
setRevealKey({ active, length, stagger });
setProgress(active ? 0 : length);
}
React.useEffect(() => {
if (!active) {
return;
}
const revealInterval = window.setInterval(
() => {
setProgress((current) => {
if (current >= length + 4) {
window.clearInterval(revealInterval);
return current;
}
return current + 1;
});
},
Math.max(16, stagger),
);
return () => {
window.clearInterval(revealInterval);
};
}, [active, length, stagger]);
return progress;
}
function useMatrixFrame({
active,
progress,
randomCharacters,
revealPlan,
segments,
}: {
active: boolean;
progress: number;
randomCharacters: string;
revealPlan: number[];
segments: string[];
}) {
const [matrixFrame, setMatrixFrame] = React.useState(() =>
segments.map(() => getRandomMatrixGlyph(randomCharacters)),
);
React.useEffect(() => {
if (!active) {
return;
}
setMatrixFrame(segments.map(() => getRandomMatrixGlyph(randomCharacters)));
const scrambleInterval = window.setInterval(() => {
setMatrixFrame((current) =>
current.map((glyph, index) => {
const isWhitespace = /^\s+$/.test(segments[index] ?? "");
const isRevealed = progress >= (revealPlan[index] ?? 0);
if (isWhitespace || isRevealed) {
return glyph;
}
return getRandomMatrixGlyph(randomCharacters);
}),
);
}, 48);
return () => {
window.clearInterval(scrambleInterval);
};
}, [active, progress, randomCharacters, revealPlan, segments]);
return matrixFrame;
}
function buildOldSchoolFrames({
matrixFrame,
progress,
randomCharacters,
revealPlan,
segments,
variant,
}: {
matrixFrame: string[];
progress: number;
randomCharacters: string;
revealPlan: number[];
segments: string[];
variant: Exclude<AnimatedTextVariant, "reveal">;
}): SegmentFrame[] {
return segments.map((segment, index) => {
const isWhitespace = /^\s+$/.test(segment);
const revealStep = revealPlan[index] ?? 0;
const isRevealed = progress >= revealStep;
let content = "";
if (variant === "matrix" || variant === "decipher") {
content = isWhitespace
? segment
: isRevealed
? segment
: (matrixFrame[index] ?? getRandomMatrixGlyph(randomCharacters));
} else if (isRevealed) {
content = segment;
}
return {
content,
isRevealed,
key: `${segment}-${index}`,
};
});
}
function useAnimatedTextFrames({
direction,
randomCharacters,
randomness,
segments,
stagger,
variant,
}: {
direction: AnimatedTextDirection;
randomCharacters: string;
randomness: number;
segments: string[];
stagger: number;
variant: AnimatedTextVariant;
}): SegmentFrame[] {
const isOldSchool = variant !== "reveal";
const revealPlan = React.useMemo(
() =>
isOldSchool
? buildRevealPlan(direction, segments.length, randomness)
: Array.from({ length: segments.length }, (_, index) => index),
[direction, isOldSchool, randomness, segments.length],
);
const progress = useRevealProgress(isOldSchool, segments.length, stagger);
const matrixFrame = useMatrixFrame({
active: variant === "matrix" || variant === "decipher",
progress,
randomCharacters,
revealPlan,
segments,
});
return React.useMemo(() => {
if (!isOldSchool) {
return buildRevealFrames(segments, stagger);
}
return buildOldSchoolFrames({
matrixFrame,
progress,
randomCharacters,
revealPlan,
segments,
variant,
});
}, [
isOldSchool,
matrixFrame,
progress,
randomCharacters,
revealPlan,
segments,
stagger,
variant,
]);
}
function getSegmentClasses(
variant: AnimatedTextVariant,
isRevealed: boolean,
): string {
if (variant === "reveal") {
return "inline-block whitespace-pre opacity-0 [animation-duration:var(--vllnt-animated-text-duration)] [animation-fill-mode:forwards] [animation-name:vllnt-animated-text-reveal] [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]";
}
if (variant === "matrix" || variant === "decipher") {
return cn(
"inline-block whitespace-pre font-mono tracking-[0.08em] transition-colors duration-150",
isRevealed ? "text-foreground" : "text-primary/75",
);
}
return "inline-block whitespace-pre font-mono";
}
function getContainerClasses(variant: AnimatedTextVariant): string {
if (variant === "matrix" || variant === "decipher") {
return "flex flex-wrap font-mono leading-relaxed tracking-[0.08em]";
}
if (variant === "terminal" || variant === "typewriter") {
return "flex flex-wrap font-mono leading-relaxed";
}
return "flex flex-wrap leading-relaxed";
}
function AnimatedTextCursor({
cursorChar,
cursorToneClass,
}: {
cursorChar: string;
cursorToneClass: string;
}) {
return (
<span
aria-hidden="true"
className={cn(
"ml-0.5 inline-block whitespace-pre font-mono [animation:vllnt-terminal-cursor-blink_1s_steps(1,end)_infinite]",
cursorToneClass,
)}
>
{cursorChar}
</span>
);
}
export const AnimatedText = ({
className,
cursor = true,
cursorChar = "█",
direction = "start",
duration = 600,
randomCharacters,
randomCharactersPreset = "matrix",
randomness = 0,
ref,
splitBy = "word",
stagger = 70,
text,
variant = "terminal",
...props
}: AnimatedTextProps & { ref?: React.Ref<HTMLParagraphElement> }) => {
const resolvedRandomCharacters = getResolvedRandomCharacters(
randomCharacters,
randomCharactersPreset,
);
const resolvedSplitBy = variant === "reveal" ? splitBy : "character";
const segments = React.useMemo(
() => getSegments(text, resolvedSplitBy),
[resolvedSplitBy, text],
);
const segmentFrames = useAnimatedTextFrames({
direction,
randomCharacters: resolvedRandomCharacters,
randomness,
segments,
stagger,
variant,
});
const showCursor =
cursor &&
variant !== "reveal" &&
segmentFrames.some((frame) => !frame.isRevealed);
const cursorToneClass = getCursorToneClass(variant);
return (
<p
aria-label={text}
className={cn(getContainerClasses(variant), className)}
ref={ref}
style={{
["--vllnt-animated-text-duration" as string]: `${duration}ms`,
}}
{...props}
>
{segmentFrames.map((segmentFrame) => (
<span
aria-hidden="true"
className={getSegmentClasses(variant, segmentFrame.isRevealed)}
key={segmentFrame.key}
style={segmentFrame.style}
>
{segmentFrame.content}
</span>
))}
{showCursor ? (
<AnimatedTextCursor
cursorChar={cursorChar}
cursorToneClass={cursorToneClass}
/>
) : null}
</p>
);
};
AnimatedText.displayName = "AnimatedText";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.