Text Animation
Five ways to put headlines and labels in motion: a glyph scramble, a Dia-browser-style colored sweep that cycles the last word, a word or letter rise-in, a shimmering gradient for loading copy, and a departure-board letter cascade for text that changes.
Text Scramble
text-scramble.tsxChange the text prop and the characters churn through random glyphs before settling on the new value within a set duration; screen readers only hear the final text, and reduced motion shows it at once.
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";
// karaui.kamisama-v0.my.id/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>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
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.tsxHolds the start of a sentence still and swaps the last word in turn, each new word painted in by a band of color that leaves plain text behind. Inspired by the Dia browser, with loop, pause and start-on-view options.
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";
// karaui.kamisama-v0.my.id/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.tsxSplits text into words or characters and lifts each piece into place as its blur clears, on mount or when scrolled into view, with stagger, delay and offset props.
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";
// karaui.kamisama-v0.my.id/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.tsxA band of light passes over the text on a loop, handy for 'Thinking…' or 'Loading' labels; choose the element type and the cycle length.
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 karaui-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: `karaui-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
// karaui.kamisama-v0.my.id/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.tsxGive it a new string and the letters flip over one at a time from left to right like a departure board, old characters falling out as new ones drop in.
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";
// karaui.kamisama-v0.my.id/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—Updated