Text Animation
Animated text primitives for spring reveals, chromatic sweeps, shimmer loading states, letter-cascade swaps and character scrambles.
Text Scramble
text-scramble.tsxA controlled character scramble that resolves changed text while keeping its final value accessible.
"use client";
import { useState } from "react";
import { TextScramble } from "@/components/motion/text-scramble";
const PHRASES = [
"Inspecting the repository",
"Running the checks",
"Preparing the update",
];
export function TextScramblePreview() {
const [index, setIndex] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-8 text-center">
<TextScramble
text={PHRASES[index]}
className="font-mono text-xl font-medium text-foreground"
/>
<button
type="button"
onClick={() => setIndex((current) => (current + 1) % PHRASES.length)}
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)"
>
Next phrase
</button>
</div>
);
}
"use client";
// beui.dev/components/motion/text-animation
import { useReducedMotion } from "motion/react";
import {
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
export interface TextScrambleProps {
/** Final text revealed by the scramble animation. */
text: string;
/** Maximum animation duration in milliseconds. */
duration?: number;
/** Characters sampled while unresolved positions are scrambling. */
glyphs?: string;
className?: string;
style?: CSSProperties;
}
/** Character scramble that resolves to `text` and respects reduced motion. */
export function TextScramble({
text,
duration,
glyphs = DEFAULT_GLYPHS,
className,
style,
}: TextScrambleProps) {
const reduce = useReducedMotion() ?? false;
const [display, setDisplay] = useState(text);
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
setDisplay(text);
return;
}
if (reduce || !glyphs) {
setDisplay(text);
return;
}
const characters = text.split("");
const startedAt = performance.now();
const animationDuration = duration
?? Math.min(760, Math.max(420, characters.length * 32));
let frame = 0;
let lastUpdate = 0;
const animate = (now: number) => {
if (now - lastUpdate >= 40) {
lastUpdate = now;
const progress = Math.min((now - startedAt) / animationDuration, 1);
const settled = Math.floor(progress * characters.length);
setDisplay(characters.map((character, index) => {
if (index < settled || character === " ") return character;
return glyphs[Math.floor(Math.random() * glyphs.length)];
}).join(""));
}
if (now - startedAt < animationDuration) {
frame = requestAnimationFrame(animate);
} else {
setDisplay(text);
}
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [duration, glyphs, reduce, text]);
return (
<span className={cn("inline-block whitespace-pre", className)} style={style}>
<span className="sr-only">{text}</span>
<span aria-hidden="true">{reduce ? text : display}</span>
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util file
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 { useReducedMotion } from "motion/react";
import {
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
export interface TextScrambleProps {
/** Final text revealed by the scramble animation. */
text: string;
/** Maximum animation duration in milliseconds. */
duration?: number;
/** Characters sampled while unresolved positions are scrambling. */
glyphs?: string;
className?: string;
style?: CSSProperties;
}
/** Character scramble that resolves to `text` and respects reduced motion. */
export function TextScramble({
text,
duration,
glyphs = DEFAULT_GLYPHS,
className,
style,
}: TextScrambleProps) {
const reduce = useReducedMotion() ?? false;
const [display, setDisplay] = useState(text);
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
setDisplay(text);
return;
}
if (reduce || !glyphs) {
setDisplay(text);
return;
}
const characters = text.split("");
const startedAt = performance.now();
const animationDuration = duration
?? Math.min(760, Math.max(420, characters.length * 32));
let frame = 0;
let lastUpdate = 0;
const animate = (now: number) => {
if (now - lastUpdate >= 40) {
lastUpdate = now;
const progress = Math.min((now - startedAt) / animationDuration, 1);
const settled = Math.floor(progress * characters.length);
setDisplay(characters.map((character, index) => {
if (index < settled || character === " ") return character;
return glyphs[Math.floor(Math.random() * glyphs.length)];
}).join(""));
}
if (now - startedAt < animationDuration) {
frame = requestAnimationFrame(animate);
} else {
setDisplay(text);
}
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [duration, glyphs, reduce, text]);
return (
<span className={cn("inline-block whitespace-pre", className)} style={style}>
<span className="sr-only">{text}</span>
<span aria-hidden="true">{reduce ? text : display}</span>
</span>
);
}
API Reference
textstringFinal text revealed by the scramble animation.
—duration?numberMaximum animation duration in milliseconds.
—glyphs?stringCharacters sampled while unresolved positions are scrambling.
ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/className?string—style?CSSProperties—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 (
// The sentence never wraps, so it has to be sized against the space it
// actually gets — the surrounding column, not the viewport.
<div className="@container flex w-full justify-center">
<ChromaticTextReveal
prefix="Motion that feels"
words={["natural.", "intentional.", "alive."]}
startOnView={false}
className="shrink-0 font-medium tracking-[-0.04em] text-foreground [font-size:clamp(1.25rem,7.8cqw,3rem)]"
/>
</div>
);
}
"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.
—wordsstring[]Words revealed one after another after the fixed prefix.
—colors?string[]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?MarginTypeIntersectionObserver 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 };
type WordGroup = { text: string; trailing: string };
/**
* One tokenizer for both modes: a line becomes the words it is made of, each
* carrying the whitespace that follows it. Word mode animates a group at a
* time, char mode the characters inside one — so the two can't drift apart on
* what counts as a word or where a space belongs. Runs of whitespace and tabs
* survive as their own group rather than collapsing.
*/
function toWordGroups(line: string): WordGroup[] {
const chunks = line.match(/\S+\s*|\s+/g) ?? [];
return chunks.map((chunk) => {
const text = chunk.replace(/\s+$/, "");
return { text, trailing: chunk.slice(text.length) };
});
}
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 lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
const renderUnit = (unit: string) => {
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}
// `whitespace-pre` is load-bearing: a unit's trailing space is
// inside an inline-block and would otherwise collapse to zero
// width, running every word together.
className="inline-block whitespace-pre will-change-transform"
>
{unit}
</motion.span>
);
};
const groups = toWordGroups(line);
const groupCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{groups.map((group) => {
const whole = group.text + group.trailing;
// Characters animate one at a time, but each word (plus the
// space that follows it) sits in its own inline-block so a long
// line wraps between words instead of mid-word.
if (split !== "char") return renderUnit(whole);
const groupCount = groupCounts.get(whole) ?? 0;
groupCounts.set(whole, groupCount + 1);
return (
<span
key={`${whole}-${groupCount}`}
className="inline-block whitespace-pre"
>
{Array.from(whole).map((char) => renderUnit(char))}
</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 };
type WordGroup = { text: string; trailing: string };
/**
* One tokenizer for both modes: a line becomes the words it is made of, each
* carrying the whitespace that follows it. Word mode animates a group at a
* time, char mode the characters inside one — so the two can't drift apart on
* what counts as a word or where a space belongs. Runs of whitespace and tabs
* survive as their own group rather than collapsing.
*/
function toWordGroups(line: string): WordGroup[] {
const chunks = line.match(/\S+\s*|\s+/g) ?? [];
return chunks.map((chunk) => {
const text = chunk.replace(/\s+$/, "");
return { text, trailing: chunk.slice(text.length) };
});
}
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 lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
const renderUnit = (unit: string) => {
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}
// `whitespace-pre` is load-bearing: a unit's trailing space is
// inside an inline-block and would otherwise collapse to zero
// width, running every word together.
className="inline-block whitespace-pre will-change-transform"
>
{unit}
</motion.span>
);
};
const groups = toWordGroups(line);
const groupCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{groups.map((group) => {
const whole = group.text + group.trailing;
// Characters animate one at a time, but each word (plus the
// space that follows it) sits in its own inline-block so a long
// line wraps between words instead of mid-word.
if (split !== "char") return renderUnit(whole);
const groupCount = groupCounts.get(whole) ?? 0;
groupCounts.set(whole, groupCount + 1);
return (
<span
key={`${whole}-${groupCount}`}
className="inline-block whitespace-pre"
>
{Array.from(whole).map((char) => renderUnit(char))}
</span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
API Reference
textstring | string[]—as?ElementTypespanclassName?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";
// The reduced-motion rule travels with the component. app/globals.css calms CSS
// animation globally, but the registry bundles imports only, so an installed
// copy never receives that reset.
//
// `!important` because the sweep is an inline style, which outranks a plain rule
// in a media query. It selects a marker class carried by TEXT_SHIMMER_CLASS_NAME
// so it also reaches consumers that build their own span out of these exports.
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}" +
"@media (prefers-reduced-motion: reduce){.beui-text-shimmer{animation:none !important}}";
export const TEXT_SHIMMER_CLASS_NAME =
"beui-text-shimmer 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?ElementTypespanduration?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 skillsInstall skillsInstall 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 { useState } from "react";
import type { ReactNode } from "react";
import { EASE_OUT, 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();
// 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 -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom",
className,
)}
style={{
clipPath: "inset(0 -999px)",
WebkitClipPath: "inset(0 -999px)",
}}
>
<span
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: 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.08em] 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"}
// Truncation lives on the layer that holds the text — the layer
// moves as a whole, so clipping it never eats the roll.
className="absolute left-0 top-[0.08em] inline-block max-w-full truncate 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—Related components
Number Animation
Animated number primitives for count-up values, rolling tickers, and fixed-slot digit swaps.
Action Swap
CTA button and slot primitives for swapping text and icons with blur motion.
Loader
Loading indicator with seventeen variants: spinner, dots, bars, dot-matrix, dither, morph, comet, scramble, metaballs, newton, helix, percent, and five terminal-style ascii spinners. Scales from one size prop, uses currentColor, and reduced-motion swaps every transform for a calm opacity pulse.
Updated