Text Animation
Animated text primitives for spring reveals, chromatic sweeps, shimmer loading states and letter-cascade swaps.
Dia Text Animation
chromatic-text-reveal.tsxA Dia-inspired text effect with a fixed sentence prefix and a cycling final word revealed by a colorful sweep.
import { ChromaticTextReveal } from "@/components/motion/chromatic-text-reveal";
export function ChromaticTextRevealPreview() {
return (
<ChromaticTextReveal
prefix="Motion that feels"
words={["natural.", "intentional.", "alive."]}
startOnView={false}
className="shrink-0 text-4xl font-medium tracking-[-0.04em] text-foreground sm:text-5xl"
/>
);
}
"use client";
// beui.dev/components/motion/text-animation
import {
type MotionStyle,
motion,
type UseInViewOptions,
useInView,
useReducedMotion,
} from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHROMATIC_PALETTE = [
"#60a5fa",
"#818cf8",
"#c084fc",
"#fb7185",
"#fbbf24",
];
const TRAIL_HALF_WIDTH = 14;
const REVEAL_START = `-${TRAIL_HALF_WIDTH}%`;
const REVEAL_FINISH = `${100 + TRAIL_HALF_WIDTH}%`;
export type ChromaticTextRevealProps = {
/** Sentence fragment that remains fixed while the final word changes. */
prefix: string;
/** Words revealed one after another after the fixed prefix. */
words: string[];
/** Colors used along the moving chromatic edge. */
colors?: string[];
/** Final text color after the sweep passes. */
foregroundColor?: string;
/** Sweep duration in seconds. */
duration?: number;
/** Delay before the first sweep, in seconds. */
delay?: number;
/** Rest after a word finishes revealing, in seconds. */
pauseDuration?: number;
/** Returns to the first word after the final word. */
loop?: boolean;
/** Starts when the text enters the viewport. */
startOnView?: boolean;
/** Only starts on the first viewport entry. */
once?: boolean;
/** IntersectionObserver root margin used by the viewport trigger. */
inViewMargin?: UseInViewOptions["margin"];
className?: string;
};
function composeChromaticGradient(colors: string[], foregroundColor: string) {
const palette = colors.length > 0 ? colors : CHROMATIC_PALETTE;
const colorStops = palette.map((color, index) => {
const offset =
palette.length === 1
? 0
: -TRAIL_HALF_WIDTH +
(index / (palette.length - 1)) * TRAIL_HALF_WIDTH * 2;
const operator = offset < 0 ? "-" : "+";
const distance = Number(Math.abs(offset).toFixed(2));
return `${color} calc(var(--chromatic-sweep) ${operator} ${distance}%)`;
});
return `linear-gradient(90deg, ${foregroundColor} 0%, ${foregroundColor} calc(var(--chromatic-sweep) - ${TRAIL_HALF_WIDTH}%), ${colorStops.join(", ")}, transparent calc(var(--chromatic-sweep) + ${TRAIL_HALF_WIDTH}%), transparent 100%)`;
}
export function ChromaticTextReveal({
prefix,
words,
colors = CHROMATIC_PALETTE,
foregroundColor = "var(--foreground)",
duration = 1.2,
delay = 0,
pauseDuration = 0.8,
loop = true,
startOnView = true,
once = true,
inViewMargin,
className,
}: ChromaticTextRevealProps) {
const ref = useRef<HTMLSpanElement>(null);
const timerRef = useRef<number | null>(null);
const [wordIndex, setWordIndex] = useState(0);
const reduceMotion = useReducedMotion();
const isInView = useInView(ref, {
once,
margin: inViewMargin,
amount: 0.4,
});
const shouldReveal = !startOnView || isInView || reduceMotion;
const backgroundImage = composeChromaticGradient(colors, foregroundColor);
const hasWords = words.length > 0;
const activeIndex = hasWords ? wordIndex % words.length : 0;
const activeWord = words[activeIndex] ?? "";
const sizingWords = Array.from(new Set(words));
const clearPendingWord = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const scheduleNextWord = useCallback(() => {
clearPendingWord();
const isLastWord = activeIndex === words.length - 1;
if (
reduceMotion ||
!shouldReveal ||
words.length < 2 ||
(isLastWord && !loop)
) {
return;
}
timerRef.current = window.setTimeout(() => {
setWordIndex((index) => (index + 1) % words.length);
}, pauseDuration * 1000);
}, [
activeIndex,
clearPendingWord,
loop,
pauseDuration,
reduceMotion,
shouldReveal,
words.length,
]);
useEffect(() => clearPendingWord, [clearPendingWord]);
return (
<span ref={ref} className={cn("inline-flex items-baseline", className)}>
<span className="whitespace-nowrap">
{prefix}
{hasWords ? "\u00A0" : null}
</span>
{hasWords ? (
<span className="relative inline-grid">
{sizingWords.map((word) => (
<span
key={word}
aria-hidden
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{word}
</span>
))}
{/* Moving a clipped text gradient defines this effect. Paint
containment bounds that deliberate repaint to the active word. */}
<motion.span
key={`${activeWord}-${activeIndex}`}
aria-hidden
initial={
reduceMotion
? false
: {
opacity: 0.56,
filter: "blur(6px)",
transform: "translateY(5px)",
}
}
animate={{
"--chromatic-sweep": shouldReveal
? REVEAL_FINISH
: REVEAL_START,
opacity: 1,
filter: "blur(0px)",
transform: "translateY(0px)",
}}
transition={{
"--chromatic-sweep": reduceMotion
? { duration: 0 }
: { duration, delay, ease: EASE_IN_OUT },
opacity: reduceMotion
? { duration: 0 }
: { duration: 0.28, ease: EASE_OUT },
filter: reduceMotion
? { duration: 0 }
: { duration: 0.36, ease: EASE_OUT },
transform: reduceMotion
? { duration: 0 }
: { duration: 0.36, ease: EASE_OUT },
}}
onAnimationComplete={scheduleNextWord}
className="absolute start-0 top-0 whitespace-nowrap bg-clip-text text-transparent [background-image:var(--chromatic-gradient)] [contain:paint]"
style={{
"--chromatic-sweep": reduceMotion
? REVEAL_FINISH
: REVEAL_START,
"--chromatic-gradient": backgroundImage,
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
} as MotionStyle}
>
{activeWord}
</motion.span>
<span className="sr-only">{activeWord}</span>
</span>
) : null}
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
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.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/text-animation
import {
type MotionStyle,
motion,
type UseInViewOptions,
useInView,
useReducedMotion,
} from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHROMATIC_PALETTE = [
"#60a5fa",
"#818cf8",
"#c084fc",
"#fb7185",
"#fbbf24",
];
const TRAIL_HALF_WIDTH = 14;
const REVEAL_START = `-${TRAIL_HALF_WIDTH}%`;
const REVEAL_FINISH = `${100 + TRAIL_HALF_WIDTH}%`;
export type ChromaticTextRevealProps = {
/** Sentence fragment that remains fixed while the final word changes. */
prefix: string;
/** Words revealed one after another after the fixed prefix. */
words: string[];
/** Colors used along the moving chromatic edge. */
colors?: string[];
/** Final text color after the sweep passes. */
foregroundColor?: string;
/** Sweep duration in seconds. */
duration?: number;
/** Delay before the first sweep, in seconds. */
delay?: number;
/** Rest after a word finishes revealing, in seconds. */
pauseDuration?: number;
/** Returns to the first word after the final word. */
loop?: boolean;
/** Starts when the text enters the viewport. */
startOnView?: boolean;
/** Only starts on the first viewport entry. */
once?: boolean;
/** IntersectionObserver root margin used by the viewport trigger. */
inViewMargin?: UseInViewOptions["margin"];
className?: string;
};
function composeChromaticGradient(colors: string[], foregroundColor: string) {
const palette = colors.length > 0 ? colors : CHROMATIC_PALETTE;
const colorStops = palette.map((color, index) => {
const offset =
palette.length === 1
? 0
: -TRAIL_HALF_WIDTH +
(index / (palette.length - 1)) * TRAIL_HALF_WIDTH * 2;
const operator = offset < 0 ? "-" : "+";
const distance = Number(Math.abs(offset).toFixed(2));
return `${color} calc(var(--chromatic-sweep) ${operator} ${distance}%)`;
});
return `linear-gradient(90deg, ${foregroundColor} 0%, ${foregroundColor} calc(var(--chromatic-sweep) - ${TRAIL_HALF_WIDTH}%), ${colorStops.join(", ")}, transparent calc(var(--chromatic-sweep) + ${TRAIL_HALF_WIDTH}%), transparent 100%)`;
}
export function ChromaticTextReveal({
prefix,
words,
colors = CHROMATIC_PALETTE,
foregroundColor = "var(--foreground)",
duration = 1.2,
delay = 0,
pauseDuration = 0.8,
loop = true,
startOnView = true,
once = true,
inViewMargin,
className,
}: ChromaticTextRevealProps) {
const ref = useRef<HTMLSpanElement>(null);
const timerRef = useRef<number | null>(null);
const [wordIndex, setWordIndex] = useState(0);
const reduceMotion = useReducedMotion();
const isInView = useInView(ref, {
once,
margin: inViewMargin,
amount: 0.4,
});
const shouldReveal = !startOnView || isInView || reduceMotion;
const backgroundImage = composeChromaticGradient(colors, foregroundColor);
const hasWords = words.length > 0;
const activeIndex = hasWords ? wordIndex % words.length : 0;
const activeWord = words[activeIndex] ?? "";
const sizingWords = Array.from(new Set(words));
const clearPendingWord = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const scheduleNextWord = useCallback(() => {
clearPendingWord();
const isLastWord = activeIndex === words.length - 1;
if (
reduceMotion ||
!shouldReveal ||
words.length < 2 ||
(isLastWord && !loop)
) {
return;
}
timerRef.current = window.setTimeout(() => {
setWordIndex((index) => (index + 1) % words.length);
}, pauseDuration * 1000);
}, [
activeIndex,
clearPendingWord,
loop,
pauseDuration,
reduceMotion,
shouldReveal,
words.length,
]);
useEffect(() => clearPendingWord, [clearPendingWord]);
return (
<span ref={ref} className={cn("inline-flex items-baseline", className)}>
<span className="whitespace-nowrap">
{prefix}
{hasWords ? "\u00A0" : null}
</span>
{hasWords ? (
<span className="relative inline-grid">
{sizingWords.map((word) => (
<span
key={word}
aria-hidden
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{word}
</span>
))}
{/* Moving a clipped text gradient defines this effect. Paint
containment bounds that deliberate repaint to the active word. */}
<motion.span
key={`${activeWord}-${activeIndex}`}
aria-hidden
initial={
reduceMotion
? false
: {
opacity: 0.56,
filter: "blur(6px)",
transform: "translateY(5px)",
}
}
animate={{
"--chromatic-sweep": shouldReveal
? REVEAL_FINISH
: REVEAL_START,
opacity: 1,
filter: "blur(0px)",
transform: "translateY(0px)",
}}
transition={{
"--chromatic-sweep": reduceMotion
? { duration: 0 }
: { duration, delay, ease: EASE_IN_OUT },
opacity: reduceMotion
? { duration: 0 }
: { duration: 0.28, ease: EASE_OUT },
filter: reduceMotion
? { duration: 0 }
: { duration: 0.36, ease: EASE_OUT },
transform: reduceMotion
? { duration: 0 }
: { duration: 0.36, ease: EASE_OUT },
}}
onAnimationComplete={scheduleNextWord}
className="absolute start-0 top-0 whitespace-nowrap bg-clip-text text-transparent [background-image:var(--chromatic-gradient)] [contain:paint]"
style={{
"--chromatic-sweep": reduceMotion
? REVEAL_FINISH
: REVEAL_START,
"--chromatic-gradient": backgroundImage,
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
} as MotionStyle}
>
{activeWord}
</motion.span>
<span className="sr-only">{activeWord}</span>
</span>
) : null}
</span>
);
}
API Reference
prefixstringSentence fragment that remains fixed while the final word changes.
—words{}Words revealed one after another after the fixed prefix.
—colors?{}Colors used along the moving chromatic edge.
[
"#60a5fa",
"#818cf8",
"#c084fc",
"#fb7185",
"#fbbf24",
]foregroundColor?stringFinal text color after the sweep passes.
var(--foreground)duration?numberSweep duration in seconds.
1.2delay?numberDelay before the first sweep, in seconds.
0pauseDuration?numberRest after a word finishes revealing, in seconds.
0.8loop?booleanReturns to the first word after the final word.
truestartOnView?booleanStarts when the text enters the viewport.
trueonce?booleanOnly starts on the first viewport entry.
trueinViewMargin?anyIntersectionObserver root margin used by the viewport trigger.
—className?string—Text Reveal
text-reveal.tsxWord or character reveal with spring slide-up and blur.
Motion that feelsconsidered.
Word by word, with a soft blur."use client";
import { useState } from "react";
import { TextReveal } from "@/components/motion/text-reveal";
export function TextRevealPreview() {
const [key, setKey] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-8 text-center">
<div key={key} className="flex flex-col gap-2">
<TextReveal
as="h2"
text={["Motion that feels", "considered."]}
className="text-balance text-4xl font-semibold leading-[0.95] tracking-[-0.04em] text-foreground sm:text-5xl"
/>
<TextReveal
text="Word by word, with a soft blur."
delay={0.9}
stagger={0.05}
blur={6}
yOffset="20%"
className="text-sm text-muted-foreground"
/>
</div>
<button
type="button"
onClick={() => setKey((k) => k + 1)}
className="inline-flex h-9 items-center rounded-full border border-border bg-card px-4 text-xs font-medium text-foreground press hover:border-(--color-border-strong)"
>
Replay
</button>
</div>
);
}
"use client";
// beui.dev/components/motion/text-animation
import { motion, type Transition, useInView, useReducedMotion } from "motion/react";
import { useRef, type ElementType, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type SplitMode = "word" | "char";
export interface TextRevealProps {
text: string | string[];
as?: ElementType;
className?: string;
split?: SplitMode;
stagger?: number;
delay?: number;
blur?: number;
yOffset?: string | number;
spring?: { stiffness?: number; damping?: number; mass?: number };
once?: boolean;
whileInView?: boolean;
children?: ReactNode;
}
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
export function TextReveal({
text,
as: Comp = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
children,
}: TextRevealProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reduce = useReducedMotion();
const shouldAnimate = whileInView ? inView : true;
const lines = Array.isArray(text) ? text : [text];
const s = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const lineCounts = new Map<string, number>();
return (
<Comp ref={ref} className={cn("block", className)}>
{lines.map((line) => {
const units = split === "word" ? line.split(" ") : Array.from(line);
const lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{units.map((unit, i) => {
const d = delay + unitIndex * stagger;
unitIndex += 1;
const unitCount = unitCounts.get(unit) ?? 0;
unitCounts.set(unit, unitCount + 1);
const unitKey = `${unit}-${unitCount}`;
const initial = reduce
? { opacity: 0 }
: { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };
const animate = shouldAnimate
? reduce
? { opacity: 1 }
: { y: 0, opacity: 1, filter: "blur(0px)" }
: initial;
const transition: Transition = reduce
? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }
: {
y: { type: "spring" as const, ...s, delay: d },
opacity: { duration: 0.7, ease: EASE_OUT, delay: d },
filter: { duration: 0.9, ease: EASE_OUT, delay: d },
};
return (
<motion.span
key={unitKey}
initial={initial}
animate={animate}
transition={transition}
className="inline-block will-change-transform"
>
{unit}
{split === "word" && i < units.length - 1 ? (
<span className="inline-block"> </span>
) : null}
</motion.span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
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.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/text-animation
import { motion, type Transition, useInView, useReducedMotion } from "motion/react";
import { useRef, type ElementType, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type SplitMode = "word" | "char";
export interface TextRevealProps {
text: string | string[];
as?: ElementType;
className?: string;
split?: SplitMode;
stagger?: number;
delay?: number;
blur?: number;
yOffset?: string | number;
spring?: { stiffness?: number; damping?: number; mass?: number };
once?: boolean;
whileInView?: boolean;
children?: ReactNode;
}
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
export function TextReveal({
text,
as: Comp = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
children,
}: TextRevealProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reduce = useReducedMotion();
const shouldAnimate = whileInView ? inView : true;
const lines = Array.isArray(text) ? text : [text];
const s = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const lineCounts = new Map<string, number>();
return (
<Comp ref={ref} className={cn("block", className)}>
{lines.map((line) => {
const units = split === "word" ? line.split(" ") : Array.from(line);
const lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{units.map((unit, i) => {
const d = delay + unitIndex * stagger;
unitIndex += 1;
const unitCount = unitCounts.get(unit) ?? 0;
unitCounts.set(unit, unitCount + 1);
const unitKey = `${unit}-${unitCount}`;
const initial = reduce
? { opacity: 0 }
: { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };
const animate = shouldAnimate
? reduce
? { opacity: 1 }
: { y: 0, opacity: 1, filter: "blur(0px)" }
: initial;
const transition: Transition = reduce
? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }
: {
y: { type: "spring" as const, ...s, delay: d },
opacity: { duration: 0.7, ease: EASE_OUT, delay: d },
filter: { duration: 0.9, ease: EASE_OUT, delay: d },
};
return (
<motion.span
key={unitKey}
initial={initial}
animate={animate}
transition={transition}
className="inline-block will-change-transform"
>
{unit}
{split === "word" && i < units.length - 1 ? (
<span className="inline-block"> </span>
) : null}
</motion.span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
API Reference
textstring | {}—as?anyspanclassName?string—split?"word" | "char"wordstagger?number0.09delay?number0blur?number12yOffset?string | number40%spring?{ stiffness?: number; damping?: number; mass?: number | undefined; } | undefined—once?booleantruewhileInView?booleanfalseText Shimmer
text-shimmer.tsxGradient sweep across text for loading or emphasis.
"use client";
import { TextShimmer } from "@/components/motion/text-shimmer";
export function TextShimmerPreview() {
return (
<div className="flex flex-col gap-4">
<TextShimmer className="text-3xl font-semibold">Loading projects…</TextShimmer>
<TextShimmer duration={1.5} className="text-sm">Faster shimmer</TextShimmer>
</div>
);
}
// beui.dev/components/motion/text-animation
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx tailwind-mergeAdd util files
import type { CSSProperties } from "react";
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}";
export const TEXT_SHIMMER_CLASS_NAME =
"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `beui-text-shimmer ${duration}s linear infinite`,
};
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
// beui.dev/components/motion/text-animation
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
API Reference
as?anyspanduration?number2.5className?string—Text Cascade
text-cascade.tsxLetter-by-letter slot roll for standalone text — old letters drop away as new ones land, left to right.
Install skills
"use client";
import { useEffect, useState } from "react";
import { TextCascade } from "@/components/motion/text-cascade";
const PHRASES = ["Install skills", "Open settings", "Ship updates"];
export function TextCascadePreview() {
const [phrase, setPhrase] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setPhrase((p) => (p + 1) % PHRASES.length);
}, 2400);
return () => window.clearInterval(id);
}, []);
return (
<div className="flex w-full justify-center">
<p className="text-lg font-medium text-foreground">
<TextCascade text={PHRASES[phrase] ?? PHRASES[0]} />
</p>
</div>
);
}
"use client";
// beui.dev/components/motion/text-animation
import { ActionSwapText } from "./action-swap";
export interface TextCascadeProps {
/** Current text. Changing it cascades the letters to the new value. */
text: string;
className?: string;
}
/**
* Letter-by-letter slot roll for standalone text — the old letters drop away
* as the new ones land, left to right. Same motion as the action-swap
* cascade variant, with a text-first API.
*/
export function TextCascade({ text, className }: TextCascadeProps) {
return (
<ActionSwapText value={text} animation="cascade" className={className}>
{text}
</ActionSwapText>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
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.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/text-animation
import { ActionSwapText } from "./action-swap";
export interface TextCascadeProps {
/** Current text. Changing it cascades the letters to the new value. */
text: string;
className?: string;
}
/**
* Letter-by-letter slot roll for standalone text — the old letters drop away
* as the new ones land, left to right. Same motion as the action-swap
* cascade variant, with a text-first API.
*/
export function TextCascade({ text, className }: TextCascadeProps) {
return (
<ActionSwapText value={text} animation="cascade" className={className}>
{text}
</ActionSwapText>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API Reference
textstringCurrent text. Changing it cascades the letters to the new value.
—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated