Text Animation
One component, three ways to animate text: a scramble that resolves into place, a word or character reveal out of a blur, and a loading shimmer sweep.
Inspecting the repository
"use client";
import { useState } from "react";
import { TextAnimation } from "@/components/motion/text-animation";
import { cn } from "@/lib/utils";
const VARIANTS = ["scramble", "reveal", "shimmer"] as const;
type Variant = (typeof VARIANTS)[number];
const PHRASES: Record<Variant, string> = {
scramble: "Inspecting the repository",
reveal: "Motion that feels considered.",
shimmer: "Loading your dashboard…",
};
export function TextAnimationPreview() {
const [variant, setVariant] = useState<Variant>("scramble");
const [replayCount, setReplayCount] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-8 text-center">
<div key={`${variant}-${replayCount}`} className="flex min-h-16 items-center justify-center">
{variant === "scramble" && (
<span className="font-mono text-xl font-medium text-foreground">
<TextAnimation variant="scramble" text={PHRASES.scramble} />
</span>
)}
{variant === "reveal" && (
<span className="text-2xl font-semibold tracking-tight text-foreground">
<TextAnimation variant="reveal" text={PHRASES.reveal} />
</span>
)}
{variant === "shimmer" && (
<span className="text-2xl font-semibold">
<TextAnimation variant="shimmer">{PHRASES.shimmer}</TextAnimation>
</span>
)}
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{VARIANTS.map((item) => (
<button
key={item}
type="button"
onClick={() => {
setVariant(item);
setReplayCount((count) => count + 1);
}}
className={cn(
"inline-flex h-9 items-center rounded-full px-4 text-xs font-medium capitalize transition-colors duration-150",
variant === item
? "bg-foreground text-background"
: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5",
)}
>
{item}
</button>
))}
<button
type="button"
onClick={() => setReplayCount((count) => count + 1)}
className="inline-flex h-9 items-center rounded-full px-4 text-xs font-medium text-foreground shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150 hover:bg-foreground/5"
>
Replay
</button>
</div>
</div>
);
}
"use client";
// easeui.dev/components/motion/text-animation
import { motion, useReducedMotion } from "motion/react";
import { type ElementType, type ReactNode, useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
/** Scramble tick rate. Fast enough to read as noise, cheap enough for a plain interval. */
const SCRAMBLE_FRAME_MS = 40;
/**
* The shimmer highlight sweeps across an oversized gradient, on loop. With a
* 200% background-size, a background-position swing of exactly 200 points
* moves the paint by exactly one tile width (the pixel shift is (container -
* image) * ΔP/100 = -container * 2, i.e. one image-width, since image =
* 2×container) — so the pattern lines back up perfectly and the loop
* restart is invisible instead of jumping.
*/
const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }];
function prefersReducedMotion() {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export interface TextAnimationProps {
/** Which animation runs. */
variant: "scramble" | "reveal" | "shimmer";
/** Text to animate. Reveal accepts an array to render each entry as its own line. Required for scramble and reveal. */
text?: string | string[];
/** Content to animate. Shimmer takes children instead of `text`, so it can wrap rich markup. */
children?: ReactNode;
className?: string;
/** Scramble: max duration in milliseconds, default 900. Shimmer: seconds per sweep, default 2.5. */
duration?: number;
/** Scramble only. Characters sampled while unresolved positions are scrambling. */
glyphs?: string;
/** Reveal only. Element the lines render inside. Default span. */
as?: ElementType;
/** Reveal only. Splits each line into words or characters. Default word. */
split?: "word" | "char";
/** Reveal only. Delay between each word or character, in seconds. Default 0.09. */
stagger?: number;
/** Reveal only. Delay before the first element, in seconds. Default 0. */
delay?: number;
/** Reveal only. Starting blur, in pixels. Default 12. */
blur?: number;
/** Reveal only. Starting vertical offset. Default "40%". */
yOffset?: string | number;
/** Reveal only. Switches from the default tween to a spring with these physical params. */
spring?: { stiffness?: number; damping?: number; mass?: number };
/** Reveal only. With whileInView, only plays the first time it enters view. Default true. */
once?: boolean;
/** Reveal only. Reveals when scrolled into view instead of on mount. Default false. */
whileInView?: boolean;
}
function ScrambleText({ text, duration = 900, glyphs = SCRAMBLE_GLYPHS, className }: TextAnimationProps) {
const [display, setDisplay] = useState(text as string);
const frame = useRef(0);
useEffect(() => {
const value = text as string;
if (prefersReducedMotion()) {
setDisplay(value);
return;
}
const totalFrames = Math.max(1, Math.round(duration / SCRAMBLE_FRAME_MS));
frame.current = 0;
const id = window.setInterval(() => {
frame.current += 1;
const progress = frame.current / totalFrames;
setDisplay(
value
.split("")
.map((char, index) => {
if (char === " ") return char;
// Resolves left to right, each position settling a bit before the next.
const resolvesAt = (index + 1) / value.length;
return progress >= resolvesAt ? char : glyphs[Math.floor(Math.random() * glyphs.length)];
})
.join(""),
);
if (frame.current >= totalFrames) window.clearInterval(id);
}, SCRAMBLE_FRAME_MS);
return () => window.clearInterval(id);
}, [text, duration, glyphs]);
return (
<span className={cn("inline-block", className)}>
<span aria-hidden="true">{display}</span>
<span className="sr-only">{text}</span>
</span>
);
}
function ShimmerText({ duration = 2.5, className, children }: TextAnimationProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = ref.current;
if (!element || typeof element.animate !== "function") return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const animation = element.animate(SHIMMER_SWEEP, {
duration: duration * 1000,
iterations: Number.POSITIVE_INFINITY,
easing: "linear",
});
let onScreen = true;
const sync = () => {
if (reducedMotion.matches || !onScreen) animation.pause();
else animation.play();
};
const observer = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
sync();
});
observer.observe(element);
reducedMotion.addEventListener("change", sync);
sync();
return () => {
observer.disconnect();
reducedMotion.removeEventListener("change", sync);
animation.cancel();
};
}, [duration]);
return (
<span
ref={ref}
className={cn("inline-block bg-clip-text text-transparent [-webkit-background-clip:text]", className)}
style={{
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)",
backgroundSize: "200% 100%",
}}
>
{children}
</span>
);
}
function splitLine(line: string, split: "word" | "char") {
return split === "char" ? Array.from(line) : line.split(" ");
}
function RevealText({
text,
as: As = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
}: TextAnimationProps) {
const reduce = useReducedMotion();
const lines = Array.isArray(text) ? text : [text as string];
// A tween reads as a clear per-word cascade at this stagger interval; a spring long
// enough to feel springy overlaps neighboring words too much and reads as one fade.
const transition = spring ? { type: "spring" as const, ...spring } : { duration: 0.4, ease: EASE_OUT };
let index = -1;
return (
<As className={cn("block", className)}>
{lines.map((line, lineIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines are a static prop, never reordered.
<span key={lineIndex} className={cn("block", split === "word" && "flex flex-wrap gap-x-[0.25em]")}>
{splitLine(line, split).map((token) => {
index += 1;
const tokenDelay = delay + index * stagger;
return (
// A wrapping overflow-hidden wound clip the blur halo into a hard-edged
// rectangle per word, which is what made this read as one blurred block
// instead of each word — so the blur is left free to bleed past the glyph.
<motion.span
key={index}
className="inline-block"
initial={reduce ? false : { opacity: 0, y: yOffset, filter: `blur(${blur}px)` }}
animate={whileInView ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }}
whileInView={whileInView ? { opacity: 1, y: 0, filter: "blur(0px)" } : undefined}
viewport={whileInView ? { once } : undefined}
transition={{ ...transition, delay: tokenDelay }}
>
{token === "" ? " " : token}
</motion.span>
);
})}
</span>
))}
</As>
);
}
/**
* One component for animated text, switched with `variant`: `scramble`
* resolves random glyphs into the final characters, `reveal` slides words or
* characters up out of a blur, and `shimmer` sweeps a highlight band across a
* loop for a loading or emphasis state. Reduced motion shows the final text
* still, with no animation.
*/
export function TextAnimation(props: TextAnimationProps) {
if (props.variant === "scramble") return <ScrambleText {...props} />;
if (props.variant === "shimmer") return <ShimmerText {...props} />;
return <RevealText {...props} />;
}
Installation
$ bunx --bun shadcn add @easeui/text-animation
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/text-animation.tsx "use client"; // easeui.dev/components/motion/text-animation import { motion, useReducedMotion } from "motion/react"; import { type ElementType, type ReactNode, useEffect, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/"; /** Scramble tick rate. Fast enough to read as noise, cheap enough for a plain interval. */ const SCRAMBLE_FRAME_MS = 40; /** * The shimmer highlight sweeps across an oversized gradient, on loop. With a * 200% background-size, a background-position swing of exactly 200 points * moves the paint by exactly one tile width (the pixel shift is (container - * image) * ΔP/100 = -container * 2, i.e. one image-width, since image = * 2×container) — so the pattern lines back up perfectly and the loop * restart is invisible instead of jumping. */ const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }]; function prefersReducedMotion() { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } export interface TextAnimationProps { /** Which animation runs. */ variant: "scramble" | "reveal" | "shimmer"; /** Text to animate. Reveal accepts an array to render each entry as its own line. Required for scramble and reveal. */ text?: string | string[]; /** Content to animate. Shimmer takes children instead of `text`, so it can wrap rich markup. */ children?: ReactNode; className?: string; /** Scramble: max duration in milliseconds, default 900. Shimmer: seconds per sweep, default 2.5. */ duration?: number; /** Scramble only. Characters sampled while unresolved positions are scrambling. */ glyphs?: string; /** Reveal only. Element the lines render inside. Default span. */ as?: ElementType; /** Reveal only. Splits each line into words or characters. Default word. */ split?: "word" | "char"; /** Reveal only. Delay between each word or character, in seconds. Default 0.09. */ stagger?: number; /** Reveal only. Delay before the first element, in seconds. Default 0. */ delay?: number; /** Reveal only. Starting blur, in pixels. Default 12. */ blur?: number; /** Reveal only. Starting vertical offset. Default "40%". */ yOffset?: string | number; /** Reveal only. Switches from the default tween to a spring with these physical params. */ spring?: { stiffness?: number; damping?: number; mass?: number }; /** Reveal only. With whileInView, only plays the first time it enters view. Default true. */ once?: boolean; /** Reveal only. Reveals when scrolled into view instead of on mount. Default false. */ whileInView?: boolean; } function ScrambleText({ text, duration = 900, glyphs = SCRAMBLE_GLYPHS, className }: TextAnimationProps) { const [display, setDisplay] = useState(text as string); const frame = useRef(0); useEffect(() => { const value = text as string; if (prefersReducedMotion()) { setDisplay(value); return; } const totalFrames = Math.max(1, Math.round(duration / SCRAMBLE_FRAME_MS)); frame.current = 0; const id = window.setInterval(() => { frame.current += 1; const progress = frame.current / totalFrames; setDisplay( value .split("") .map((char, index) => { if (char === " ") return char; // Resolves left to right, each position settling a bit before the next. const resolvesAt = (index + 1) / value.length; return progress >= resolvesAt ? char : glyphs[Math.floor(Math.random() * glyphs.length)]; }) .join(""), ); if (frame.current >= totalFrames) window.clearInterval(id); }, SCRAMBLE_FRAME_MS); return () => window.clearInterval(id); }, [text, duration, glyphs]); return ( <span className={cn("inline-block", className)}> <span aria-hidden="true">{display}</span> <span className="sr-only">{text}</span> </span> ); } function ShimmerText({ duration = 2.5, className, children }: TextAnimationProps) { const ref = useRef<HTMLSpanElement>(null); useEffect(() => { const element = ref.current; if (!element || typeof element.animate !== "function") return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const animation = element.animate(SHIMMER_SWEEP, { duration: duration * 1000, iterations: Number.POSITIVE_INFINITY, easing: "linear", }); let onScreen = true; const sync = () => { if (reducedMotion.matches || !onScreen) animation.pause(); else animation.play(); }; const observer = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; sync(); }); observer.observe(element); reducedMotion.addEventListener("change", sync); sync(); return () => { observer.disconnect(); reducedMotion.removeEventListener("change", sync); animation.cancel(); }; }, [duration]); return ( <span ref={ref} className={cn("inline-block bg-clip-text text-transparent [-webkit-background-clip:text]", className)} style={{ backgroundImage: "linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)", backgroundSize: "200% 100%", }} > {children} </span> ); } function splitLine(line: string, split: "word" | "char") { return split === "char" ? Array.from(line) : line.split(" "); } function RevealText({ text, as: As = "span", className, split = "word", stagger = 0.09, delay = 0, blur = 12, yOffset = "40%", spring, once = true, whileInView = false, }: TextAnimationProps) { const reduce = useReducedMotion(); const lines = Array.isArray(text) ? text : [text as string]; // A tween reads as a clear per-word cascade at this stagger interval; a spring long // enough to feel springy overlaps neighboring words too much and reads as one fade. const transition = spring ? { type: "spring" as const, ...spring } : { duration: 0.4, ease: EASE_OUT }; let index = -1; return ( <As className={cn("block", className)}> {lines.map((line, lineIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: lines are a static prop, never reordered. <span key={lineIndex} className={cn("block", split === "word" && "flex flex-wrap gap-x-[0.25em]")}> {splitLine(line, split).map((token) => { index += 1; const tokenDelay = delay + index * stagger; return ( // A wrapping overflow-hidden wound clip the blur halo into a hard-edged // rectangle per word, which is what made this read as one blurred block // instead of each word — so the blur is left free to bleed past the glyph. <motion.span key={index} className="inline-block" initial={reduce ? false : { opacity: 0, y: yOffset, filter: `blur(${blur}px)` }} animate={whileInView ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }} whileInView={whileInView ? { opacity: 1, y: 0, filter: "blur(0px)" } : undefined} viewport={whileInView ? { once } : undefined} transition={{ ...transition, delay: tokenDelay }} > {token === "" ? " " : token} </motion.span> ); })} </span> ))} </As> ); } /** * One component for animated text, switched with `variant`: `scramble` * resolves random glyphs into the final characters, `reveal` slides words or * characters up out of a blur, and `shimmer` sweeps a highlight band across a * loop for a loading or emphasis state. Reduced motion shows the final text * still, with no animation. */ export function TextAnimation(props: TextAnimationProps) { if (props.variant === "scramble") return <ScrambleText {...props} />; if (props.variant === "shimmer") return <ShimmerText {...props} />; return <RevealText {...props} />; }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "scramble" | "reveal" | "shimmer" | - | Which animation runs. |
| text? | string | {} | - | Text to animate. Reveal accepts an array to render each entry as its own line. Required for scramble and reveal. |
| children? | any | - | Content to animate. Shimmer takes children instead of `text`, so it can wrap rich markup. |
| className? | string | - | - |
| duration? | number | - | Scramble: max duration in milliseconds, default 900. Shimmer: seconds per sweep, default 2.5. |
| glyphs? | string | - | Scramble only. Characters sampled while unresolved positions are scrambling. |
| as? | any | - | Reveal only. Element the lines render inside. Default span. |
| split? | "word" | "char" | - | Reveal only. Splits each line into words or characters. Default word. |
| stagger? | number | - | Reveal only. Delay between each word or character, in seconds. Default 0.09. |
| delay? | number | - | Reveal only. Delay before the first element, in seconds. Default 0. |
| blur? | number | - | Reveal only. Starting blur, in pixels. Default 12. |
| yOffset? | string | number | - | Reveal only. Starting vertical offset. Default "40%". |
| spring? | { stiffness?: number; damping?: number; mass?: number | undefined; } | undefined | - | Reveal only. Switches from the default tween to a spring with these physical params. |
| once? | boolean | - | Reveal only. With whileInView, only plays the first time it enters view. Default true. |
| whileInView? | boolean | - | Reveal only. Reveals when scrolled into view instead of on mount. Default false. |
Updated