Text Animation
React NativeReveals, sweeps, shimmers, cascades and scrambles.
Preview


Install
Source on GitHubIt works in Expo and in bare React Native. New to beUI Native? Set up your app first.
Copies the component and the shared files it needs into src/, adds the theme tokens if they're missing, and installs its libraries. Files you've changed are kept.
Keep the folder layout, since the component imports the shared files by relative path.
1Install the libraries
npx expo install react-native-reanimated react-native-worklets
npm install uniwind tailwindcss2Add the theme tokens
Once per app. Import it from the CSS file Uniwind reads, after Tailwind and Uniwind, and change the values to match your brand.
/* Matches the default beUI palette in ui-components/lib/themes.ts.
Native surface maps to card; muted maps to muted-foreground. */
@layer theme {
:root {
@variant light {
--color-background: oklch(99% 0 0);
--color-surface: oklch(97% 0 0);
--color-foreground: oklch(15% 0 0);
--color-muted: oklch(50% 0 0);
--color-border: oklch(15% 0 0 / 0.06);
--color-border-strong: oklch(15% 0 0 / 0.16);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #dc2626;
--color-success: #059669;
--color-warning: #d97706;
}
}
:root {
@variant dark {
--color-background: #151515;
--color-surface: #1c1c1c;
--color-foreground: oklch(96% 0 0);
--color-muted: oklch(62% 0 0);
--color-border: rgb(255 255 255 / 0.05);
--color-border-strong: rgb(255 255 255 / 0.14);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #f87171;
--color-success: #34d399;
--color-warning: #fbbf24;
}
}
}
3Copy the shared files
import { useEffect, useRef, useState } from "react";
export type PresenceItem<T> = {
key: string;
value: T;
/** False for the item present on first render, so it does not animate in. */
appear: boolean;
exiting: boolean;
};
/**
* Keeps replaced items mounted while they animate out, like AnimatePresence.
* A key that returns while exiting is revived in place.
*/
export function usePresence<T>(
key: string | null,
value: T,
exitDuration: (value: T) => number,
) {
const [items, setItems] = useState<PresenceItem<T>[]>(() =>
key === null ? [] : [{ key, value, appear: false, exiting: false }],
);
const [currentKey, setCurrentKey] = useState(key);
const exitDurationRef = useRef(exitDuration);
exitDurationRef.current = exitDuration;
// Adjust during render so the new item and the exits start in the same frame.
if (key !== currentKey) {
setCurrentKey(key);
setItems((previous) => {
const next = previous.map((item) =>
item.key === key
? { ...item, value, exiting: false }
: { ...item, exiting: true },
);
if (key !== null && !previous.some((item) => item.key === key)) {
next.push({ key, value, appear: true, exiting: false });
}
return next;
});
}
const exitingKeys = items
.filter((item) => item.exiting)
.map((item) => item.key)
.join("\n");
useEffect(() => {
if (!exitingKeys) return;
const timers = items
.filter((item) => item.exiting)
.map((item) =>
setTimeout(() => {
setItems((previous) =>
previous.filter(
(candidate) => !(candidate.key === item.key && candidate.exiting),
),
);
}, exitDurationRef.current(item.value)),
);
return () => {
for (const timer of timers) clearTimeout(timer);
};
// Restart removal timers only when the set of exiting items changes.
}, [exitingKeys]);
return items;
}
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
import { useReducedMotion } from "react-native-reanimated";
/** Reanimated supplies a synchronous initial value; native events keep it current. */
export function useReducedMotionPreference() {
const initialValue = useReducedMotion();
const [reducedMotion, setReducedMotion] = useState(initialValue);
useEffect(() => {
let active = true;
let receivedEvent = false;
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
(value) => {
receivedEvent = true;
setReducedMotion(value);
},
);
void AccessibilityInfo.isReduceMotionEnabled().then((value) => {
if (active && !receivedEvent) setReducedMotion(value);
});
return () => {
active = false;
subscription.remove();
};
}, []);
return reducedMotion;
}
import { type ReactNode, useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated";
import { usePresence } from "../hooks/use-presence";
import { motion } from "../styles/motion";
const CASCADE_STAGGER = 25;
const TEXT_DISTANCE = 14;
// Splitting into letters breaks shaping in connected and complex scripts.
const CASCADE_SAFE = /^[\u0020-\u007e\u00a0-\u024f]+$/;
function letters(text: string) {
// Whitespace-only Text can measure zero-width on Android.
return Array.from(text.replace(/ /g, "\u00a0"));
}
function exitDuration(text: string, cascade: boolean) {
const stagger = cascade ? (text.length - 1) * CASCADE_STAGGER * 0.5 : 0;
return motion.exit.duration + stagger + 40;
}
/** Progress runs 0 (below) → 1 (resting) → 2 (exited above). */
function Slide({
appear,
exiting,
distance,
delay,
reduce,
children,
}: {
appear: boolean;
exiting: boolean;
distance: number;
delay: number;
reduce: boolean;
children: ReactNode;
}) {
const progress = useSharedValue(appear ? 0 : 1);
useEffect(() => {
if (reduce) {
progress.value = withTiming(exiting ? 2 : 1, motion.fade);
} else if (exiting) {
progress.value = withDelay(delay / 2, withTiming(2, motion.exit));
} else {
progress.value = withDelay(delay, withSpring(1, motion.swap));
}
}, [delay, exiting, progress, reduce]);
const animatedStyle = useAnimatedStyle(() => {
const p = progress.value;
return {
opacity: Math.min(1, Math.max(0, p <= 1 ? p : 2 - p)),
transform: [{ translateY: reduce ? 0 : (1 - p) * distance }],
};
}, [distance, reduce]);
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
}
function Label({
text,
cascade,
className,
}: {
text: string;
cascade: boolean;
className: string;
}) {
return cascade ? (
letters(text).map((char, index) => (
<Text key={index} className={className}>
{char}
</Text>
))
) : (
<Text numberOfLines={1} className={className}>
{text}
</Text>
);
}
export type CascadeTextProps = {
/** Changing the key swaps in `text`, even when the text itself repeats. */
textKey: string;
text: string;
/** Text style applied to every letter. */
className: string;
reduce: boolean;
};
/**
* Letter-by-letter slot roll: old letters roll up and away as new ones spring
* in from below, left to right, while the width springs to fit. Shared by
* StatefulButton and TextCascade.
*/
export function CascadeText({
textKey,
text,
className,
reduce,
}: CascadeTextProps) {
const canCascade = (value: string) => !reduce && CASCADE_SAFE.test(value);
const items = usePresence(textKey, text, (value) =>
reduce ? motion.fade.duration + 40 : exitDuration(value, canCascade(value)),
);
const [measured, setMeasured] = useState<{
width: number;
height: number;
} | null>(null);
const width = useSharedValue(0);
const widthStyle = useAnimatedStyle(() => ({ width: width.value }));
const labelClass = className;
const distance = measured ? measured.height * 1.05 : 20;
return (
<Animated.View
style={[
styles.textSlot,
measured && { height: measured.height },
measured && widthStyle,
]}
>
{/* Measures the incoming label with the same per-letter layout it renders with. */}
<View style={styles.measureHost}>
<View
style={styles.row}
onLayout={({ nativeEvent: { layout } }) => {
if (measured) {
width.value = reduce
? layout.width
: withSpring(layout.width, motion.swap);
} else {
width.value = layout.width;
}
setMeasured({ width: layout.width, height: layout.height });
}}
>
<Label text={text} cascade={canCascade(text)} className={className} />
</View>
</View>
{items.map((item) => {
const cascade = canCascade(item.value);
return (
<View key={item.key} style={[styles.row, measured && styles.layer]}>
{cascade ? (
letters(item.value).map((char, index) => (
<Slide
key={index}
appear={item.appear}
exiting={item.exiting}
distance={distance}
delay={index * CASCADE_STAGGER}
reduce={reduce}
>
<Text className={labelClass}>{char}</Text>
</Slide>
))
) : (
<Slide
appear={item.appear}
exiting={item.exiting}
distance={TEXT_DISTANCE}
delay={0}
reduce={reduce}
>
<Text numberOfLines={1} className={labelClass}>
{item.value}
</Text>
</Slide>
)}
</View>
);
})}
</Animated.View>
);
}
const styles = StyleSheet.create({
textSlot: { overflow: "hidden", flexShrink: 0 },
row: { flexDirection: "row", alignItems: "center" },
layer: { position: "absolute", left: 0, top: 0 },
measureHost: {
position: "absolute",
left: 0,
top: 0,
width: 10000,
flexDirection: "row",
alignItems: "flex-start",
opacity: 0,
pointerEvents: "none",
},
});
import { StyleSheet, View, type ViewStyle } from "react-native";
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
/**
* Returns a glyph's color for the current progress. `position` is the glyph's
* center across the row, from 0 at the left edge to 1 at the right. Must be a
* worklet.
*/
export type GlyphColor = (
progress: number,
position: number,
) => { color: string; opacity: number };
// Splitting into glyphs breaks shaping in connected and complex scripts.
const SPLIT_SAFE = /^[ -~ -ɏ]+$/;
export type GlyphRowProps = {
text: string;
/** Text style for every glyph. Its color is replaced by `colorAt`. */
className?: string;
progress: SharedValue<number>;
colorAt: GlyphColor;
style?: ViewStyle;
};
function Glyph({
char,
className,
progress,
rowWidth,
colorAt,
}: {
char: string;
className?: string;
progress: SharedValue<number>;
rowWidth: SharedValue<number>;
colorAt: GlyphColor;
}) {
const center = useSharedValue(-1);
const animatedStyle = useAnimatedStyle(() => {
const width = rowWidth.value;
// Until measured, assume the middle so nothing flashes at the edges.
const position =
width > 0 && center.value >= 0 ? center.value / width : 0.5;
return colorAt(progress.value, position);
});
return (
<Animated.Text
className={className}
style={animatedStyle}
onLayout={({ nativeEvent: { layout } }) => {
center.value = layout.x + layout.width / 2;
}}
>
{char}
</Animated.Text>
);
}
/**
* Text drawn one glyph at a time so each glyph can take its own animated
* color, standing in for web gradients clipped to text. Scripts that need
* shaping render as a single glyph so their letters still join.
*/
export function GlyphRow({
text,
className,
progress,
colorAt,
style,
}: GlyphRowProps) {
const rowWidth = useSharedValue(0);
// Whitespace-only Text can measure zero-width on Android.
const glyphs = SPLIT_SAFE.test(text)
? Array.from(text.replace(/ /g, " "))
: [text];
return (
<View
style={[styles.row, style]}
onLayout={({ nativeEvent: { layout } }) => {
rowWidth.value = layout.width;
}}
>
{glyphs.map((char, index) => (
<Glyph
// Position is the glyph's identity within one string.
key={`${index}-${char}`}
char={char}
className={className}
progress={progress}
rowWidth={rowWidth}
colorAt={colorAt}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: "row", alignItems: "baseline" },
});
import { Easing, ReduceMotion } from "react-native-reanimated";
// Mirrors beUI's lib/ease.ts so native motion matches the web library.
export const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
export const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
stiffness: 500,
damping: 30,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Content swaps: label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
stiffness: 460,
damping: 30,
mass: 0.55,
reduceMotion: ReduceMotion.System,
} as const;
/** Shared-layout glides: pills, indicators and panels moving between positions. */
export const SPRING_LAYOUT = {
stiffness: 360,
damping: 32,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Dragged handles and fills; critically damped so values never rebound off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
reduceMotion: ReduceMotion.System,
} as const;
export const motion = {
press: SPRING_PRESS,
swap: SPRING_SWAP,
layout: SPRING_LAYOUT,
glide: SPRING_GLIDE,
exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },
fade: { duration: 150, reduceMotion: ReduceMotion.System },
} as const;
4Copy the component
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View, type ViewProps } from "react-native";
import Animated, {
cancelAnimation,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { GlyphRow } from "../../primitives/glyph-row";
import { EASE_IN_OUT, EASE_OUT } from "../../styles/motion";
const PALETTE = ["#60a5fa", "#818cf8", "#c084fc", "#fb7185", "#fbbf24"];
// The colored trail spans 14% of the word either side of the sweep.
const TRAIL = 0.14;
const SWEEP_START = -TRAIL;
const SWEEP_END = 1 + TRAIL;
export type ChromaticTextRevealProps = Omit<ViewProps, "children"> & {
/** Fixed sentence fragment before the changing word. */
prefix: string;
/** Words revealed one after another after the prefix. */
words: string[];
/** Colors along the moving chromatic edge. */
colors?: string[];
/** Sweep duration in milliseconds. Defaults to 1200. */
duration?: number;
/** Delay before each sweep in milliseconds. */
delay?: number;
/** Rest after a word finishes revealing, in milliseconds. Defaults to 800. */
pauseDuration?: number;
/** Returns to the first word after the last. Defaults to true. */
loop?: boolean;
/** Text style for the whole sentence, such as `text-3xl font-semibold`. */
className?: string;
/** Disables sweeping and cycling independently of the system preference. */
disableAnimation?: boolean;
};
function Word({
word,
className,
palette,
foreground,
duration,
delay,
reduce,
onRevealed,
}: {
word: string;
className: string;
palette: string[];
foreground: string;
duration: number;
delay: number;
reduce: boolean;
onRevealed: () => void;
}) {
const sweep = useSharedValue(reduce ? SWEEP_END : SWEEP_START);
// A ref keeps a new callback identity from restarting the sweep.
const revealedRef = useRef(onRevealed);
revealedRef.current = onRevealed;
const enter = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
if (reduce) return;
enter.value = withTiming(1, { duration: 360, easing: EASE_OUT });
sweep.value = withDelay(
delay,
withTiming(SWEEP_END, { duration, easing: EASE_IN_OUT }),
);
const timer = setTimeout(() => revealedRef.current(), delay + duration);
return () => {
clearTimeout(timer);
cancelAnimation(sweep);
};
}, [delay, duration, enter, reduce, sweep]);
const stops = palette.map((_, index) =>
palette.length === 1
? 0
: -TRAIL + (index / (palette.length - 1)) * TRAIL * 2,
);
// Behind the sweep the word is solid; inside the trail it takes the palette;
// ahead of it the word is still invisible.
const colorAt = (s: number, position: number) => {
"worklet";
const offset = position - s;
if (offset < -TRAIL) return { color: foreground, opacity: 1 };
if (offset > TRAIL) return { color: foreground, opacity: 0 };
return {
color:
palette.length === 1
? (palette[0] ?? foreground)
: interpolateColor(offset, stops, palette),
opacity: 1,
};
};
const enterStyle = useAnimatedStyle(() => ({
opacity: 0.56 + 0.44 * Math.min(1, enter.value * (360 / 280)),
transform: [{ translateY: 5 * (1 - enter.value) }],
}));
return (
<Animated.View style={[styles.word, enterStyle]}>
<GlyphRow
text={word}
className={className}
progress={sweep}
colorAt={colorAt}
/>
</Animated.View>
);
}
/** A sentence whose final word is revealed by a moving chromatic sweep, then cycles. */
export function ChromaticTextReveal({
prefix,
words,
colors = PALETTE,
duration = 1200,
delay = 0,
pauseDuration = 800,
loop = true,
className = "",
disableAnimation = false,
accessibilityLabel,
...props
}: ChromaticTextRevealProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const { color: foreground } = useResolveClassNames("text-foreground");
const [index, setIndex] = useState(0);
const [cycle, setCycle] = useState(0);
const palette = colors.length > 0 ? colors : PALETTE;
const active = words.length > 0 ? index % words.length : 0;
const word = words[active] ?? "";
const sizing = Array.from(new Set(words));
const [revealed, setRevealed] = useState(false);
useEffect(() => {
if (!revealed) return;
const last = active === words.length - 1;
if (reduce || words.length < 2 || (last && !loop)) return;
const timer = setTimeout(() => {
setRevealed(false);
setIndex((current) => (current + 1) % words.length);
setCycle((current) => current + 1);
}, pauseDuration);
return () => clearTimeout(timer);
}, [active, loop, pauseDuration, reduce, revealed, words.length]);
return (
<View
{...props}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? `${prefix} ${word}`}
style={[styles.sentence, props.style]}
>
<Text className={className}>
{prefix}
{words.length > 0 ? " " : null}
</Text>
{words.length > 0 ? (
<View>
{/* The widest word reserves space so the sentence never reflows. */}
{sizing.map((candidate) => (
<Text key={candidate} className={className} style={styles.sizer}>
{candidate}
</Text>
))}
<Word
key={`${word}-${cycle}`}
word={word}
className={className}
palette={palette}
foreground={String(foreground ?? "black")}
duration={duration}
delay={delay}
reduce={reduce}
onRevealed={() => setRevealed(true)}
/>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
sentence: {
flexDirection: "row",
alignItems: "flex-start",
alignSelf: "flex-start",
},
// Stacked invisibly in one cell; the tallest and widest sets the size.
sizer: { opacity: 0, height: 0, overflow: "hidden" },
word: { position: "absolute", left: 0, top: 0 },
});
export type { ChromaticTextRevealProps } from "./chromatic-text-reveal";
export { ChromaticTextReveal } from "./chromatic-text-reveal";
export type { TextCascadeProps } from "./text-cascade";
export { TextCascade } from "./text-cascade";
export type { TextRevealProps } from "./text-reveal";
export { TextReveal } from "./text-reveal";
export type { TextScrambleProps } from "./text-scramble";
export { TextScramble } from "./text-scramble";
export type { TextShimmerProps } from "./text-shimmer";
export { TextShimmer } from "./text-shimmer";
import { View, type ViewProps } from "react-native";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { CascadeText } from "../../primitives/cascade-text";
export type TextCascadeProps = Omit<ViewProps, "children"> & {
/** Current text. Changing it cascades the letters to the new value. */
text: string;
/** Text style for every letter. */
className?: string;
/** Disables the cascade independently of the system preference. */
disableAnimation?: boolean;
};
/**
* Letter-by-letter slot roll for standalone text: old letters roll away as
* new ones land, left to right. The same motion as StatefulButton's labels.
*/
export function TextCascade({
text,
className = "",
disableAnimation = false,
accessibilityLabel,
...props
}: TextCascadeProps) {
const reducedMotion = useReducedMotionPreference();
return (
<View
{...props}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? text}
style={[{ alignSelf: "flex-start" }, props.style]}
>
<CascadeText
textKey={text}
text={text}
className={className}
reduce={reducedMotion || disableAnimation}
/>
</View>
);
}
import { useEffect } from "react";
import { StyleSheet, Text, View, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { EASE_OUT } from "../../styles/motion";
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
type Offset = number | `${number}%`;
export type TextRevealProps = Omit<ViewProps, "children"> & {
/** One string, or several lines. */
text: string | string[];
/** Animates whole words (the default) or single characters. */
split?: "word" | "char";
/** Delay between units in milliseconds. Defaults to 90. */
stagger?: number;
/** Delay before the first unit in milliseconds. */
delay?: number;
/** Rise distance: points, or a percentage of the line height. Defaults to "40%". */
yOffset?: Offset;
spring?: { stiffness?: number; damping?: number; mass?: number };
/** Text style for every unit, such as `text-3xl font-semibold`. */
className?: string;
/** Disables the rise independently of the system preference. */
disableAnimation?: boolean;
};
type WordGroup = { text: string; trailing: string };
/** Splits a line into words, each carrying the whitespace that follows it. */
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) };
});
}
// Whitespace-only Text can measure zero-width on Android.
const keepSpaces = (value: string) => value.replace(/ /g, " ");
function Unit({
text,
delay,
offset,
spring,
reduce,
className,
}: {
text: string;
delay: number;
offset: Offset;
spring: typeof DEFAULT_SPRING;
reduce: boolean;
className: string;
}) {
const rise = useSharedValue(0);
const fade = useSharedValue(0);
const height = useSharedValue(0);
useEffect(() => {
if (reduce) {
fade.value = withDelay(
delay * 0.3,
withTiming(1, { duration: 250, easing: EASE_OUT }),
);
rise.value = 1;
return;
}
const { stiffness, damping, mass } = spring;
rise.value = withDelay(delay, withSpring(1, { stiffness, damping, mass }));
fade.value = withDelay(
delay,
withTiming(1, { duration: 700, easing: EASE_OUT }),
);
// Depend on the spring's numbers, not its object identity.
}, [
delay,
fade,
reduce,
rise,
spring.stiffness,
spring.damping,
spring.mass,
]);
const percent = typeof offset === "string" ? Number.parseFloat(offset) : null;
const animatedStyle = useAnimatedStyle(() => {
const distance =
percent === null ? (offset as number) : (height.value * percent) / 100;
return {
opacity: fade.value,
transform: [{ translateY: (1 - rise.value) * distance }],
};
});
return (
<Animated.View
style={animatedStyle}
onLayout={({ nativeEvent: { layout } }) => {
height.value = layout.height;
}}
>
<Text className={className}>{keepSpaces(text)}</Text>
</Animated.View>
);
}
/** Words or characters that rise into place on a spring, one after another. */
export function TextReveal({
text,
split = "word",
stagger = 90,
delay = 0,
yOffset = "40%",
spring,
className = "",
disableAnimation = false,
accessibilityLabel,
...props
}: TextRevealProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const lines = Array.isArray(text) ? text : [text];
const physics = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const renderUnit = (unit: string, key: string) => {
const unitDelay = delay + unitIndex * stagger;
unitIndex += 1;
return (
<Unit
key={key}
text={unit}
delay={unitDelay}
offset={yOffset}
spring={physics}
reduce={reduce}
className={className}
/>
);
};
return (
<View
{...props}
accessible
accessibilityLabel={accessibilityLabel ?? lines.join("\n")}
>
{lines.map((line, lineIndex) => (
// Lines and units are positional; the whole tree remounts with new text.
<View key={`${lineIndex}-${line}`} style={styles.line}>
{toWordGroups(line).map((group, groupIndex) => {
const whole = group.text + group.trailing;
const key = `${groupIndex}-${whole}`;
if (split !== "char") return renderUnit(whole, key);
// Each word stays together so lines wrap between words, not inside them.
return (
<View key={key} style={styles.word}>
{Array.from(whole).map((char, charIndex) =>
renderUnit(char, `${charIndex}-${char}`),
)}
</View>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
line: { flexDirection: "row", flexWrap: "wrap" },
word: { flexDirection: "row" },
});
import { useEffect, useRef, useState } from "react";
import { Text, type TextProps } from "react-native";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
const DEFAULT_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
// Scrambled glyphs change at most this often, in milliseconds.
const FRAME = 40;
export type TextScrambleProps = Omit<TextProps, "children"> & {
/** Final text. Changing it scrambles toward the new value. */
text: string;
/** Total duration in milliseconds. Defaults to 32 ms per character, within 420–760. */
duration?: number;
/** Characters sampled while positions are unresolved. */
glyphs?: string;
/** Disables scrambling independently of the system preference. */
disableAnimation?: boolean;
className?: string;
};
/** Characters scramble through random glyphs and settle left to right on `text`. */
export function TextScramble({
text,
duration,
glyphs = DEFAULT_GLYPHS,
disableAnimation = false,
className = "",
accessibilityLabel,
...props
}: TextScrambleProps) {
const reducedMotion = useReducedMotionPreference();
const animate = !reducedMotion && !disableAnimation;
const [display, setDisplay] = useState(text);
const mounted = useRef(false);
useEffect(() => {
// The first text appears as is; later changes scramble.
if (!mounted.current) {
mounted.current = true;
setDisplay(text);
return;
}
if (!animate || !glyphs) {
setDisplay(text);
return;
}
const characters = Array.from(text);
const total =
duration ?? Math.min(760, Math.max(420, characters.length * 32));
const startedAt = Date.now();
let lastUpdate = 0;
let frame = 0;
const tick = () => {
const now = Date.now();
if (now - lastUpdate >= FRAME) {
lastUpdate = now;
const settled = Math.floor(
Math.min((now - startedAt) / total, 1) * characters.length,
);
setDisplay(
characters
.map((character, index) =>
index < settled || character === " "
? character
: glyphs[Math.floor(Math.random() * glyphs.length)],
)
.join(""),
);
}
if (now - startedAt < total) frame = requestAnimationFrame(tick);
else setDisplay(text);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [animate, duration, glyphs, text]);
return (
<Text
{...props}
// Screen readers get the final text, never the scrambled frames.
accessibilityLabel={accessibilityLabel ?? text}
className={className}
>
{animate ? display : text}
</Text>
);
}
import { useEffect } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
cancelAnimation,
Easing,
interpolateColor,
ReduceMotion,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { GlyphRow } from "../../primitives/glyph-row";
// The web gradient is twice the text's width with its highlight 40% either side
// of its middle, swept from 200% to -200% background position. In text widths
// that is a highlight 0.4 wide whose center travels from -1 to 3.
const BAND = 0.4;
const SWEEP_FROM = -1;
const SWEEP_TO = 3;
export type TextShimmerProps = Omit<ViewProps, "children"> & {
children: string;
/** One sweep in milliseconds. Defaults to 2500. */
duration?: number;
/** Text style, such as `text-xl font-semibold`. Colors come from the theme. */
className?: string;
/** Disables the sweep independently of the system preference. */
disableAnimation?: boolean;
};
/** A highlight that sweeps across muted text, for loading and thinking states. */
export function TextShimmer({
children,
duration = 2500,
className = "",
disableAnimation = false,
accessibilityLabel,
...props
}: TextShimmerProps) {
const reducedMotion = useReducedMotionPreference();
const animate = !reducedMotion && !disableAnimation;
const { color: base } = useResolveClassNames("text-muted");
const { color: highlight } = useResolveClassNames("text-foreground");
const progress = useSharedValue(0);
const baseColor = String(base ?? "gray");
const highlightColor = String(highlight ?? "black");
useEffect(() => {
if (!animate) {
cancelAnimation(progress);
progress.value = 0;
return;
}
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration,
easing: Easing.linear,
reduceMotion: ReduceMotion.Never,
}),
-1,
);
return () => cancelAnimation(progress);
}, [animate, duration, progress]);
const colorAt = (p: number, position: number) => {
"worklet";
const center = SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * p;
const lit = Math.max(0, 1 - Math.abs(position - center) / BAND);
return {
color: interpolateColor(lit, [0, 1], [baseColor, highlightColor]),
opacity: 1,
};
};
return (
<View
{...props}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? children}
style={[{ alignSelf: "flex-start" }, props.style]}
>
{animate ? (
<GlyphRow
text={children}
className={className}
progress={progress}
colorAt={colorAt}
/>
) : (
<Animated.Text className={className} style={{ color: baseColor }}>
{children}
</Animated.Text>
)}
</View>
);
}
Documentation
Native port of beUI Text Animation: five primitives for text that arrives or changes.
import {
ChromaticTextReveal,
TextCascade,
TextReveal,
TextScramble,
TextShimmer,
} from "./src";
<TextReveal text="Motion in words." className="text-3xl font-semibold text-foreground" />
<ChromaticTextReveal prefix="Motion that feels" words={["natural.", "alive."]} />
<TextShimmer className="text-xl font-semibold">Thinking</TextShimmer>
<TextCascade text={status} />
<TextScramble text={codename} />TextReveal
Words (or characters with split="char") rise into place one after another, 90 ms apart. Each rises on a soft spring (stiffness 140, damping 26, mass 1.2) and fades in over 700 ms. yOffset takes points or a percentage of the line height ("40%" by default). Lines wrap between words, never inside them. Pass an array for explicit lines. Change key to replay it.
ChromaticTextReveal
A fixed prefix followed by a word revealed by a moving colour sweep. Behind the sweep the word is solid, inside a trail 14% of the word either side it takes the colors palette, and ahead of it the word is still invisible. After each 1200 ms sweep and an 800 ms pause, the next word arrives. The space reserved is the widest word's, so the sentence never reflows.
TextShimmer
A highlight that sweeps across muted text every 2500 ms, for loading and thinking states. The colours come from the theme's muted and foreground tokens.
TextCascade
The letter cascade from StatefulButton for standalone text. When text changes, the old letters roll up and away as the new ones spring in from below, left to right, and the width springs to fit.
TextScramble
When text changes, every unresolved character cycles through random glyphs, settling left to right on the new text within 420–760 ms. The first text appears as is.
Shared behavior
classNamestyles every glyph or word, such astext-xl font-semibold text-foreground.- Each component is one accessible text element whose label is the final text.
- Reduced motion, or
disableAnimation, shows text without movement. TextReveal fades, ChromaticTextReveal stays on the first word fully revealed, TextShimmer stays muted, TextCascade crossfades, and TextScramble swaps instantly. - Durations are in milliseconds.
Native can't clip a gradient to text without extra libraries. The chromatic sweep and the shimmer therefore colour each character individually from the moving gradient, using the shared GlyphRow primitive. Scripts that need letter shaping, such as Arabic or Devanagari, are coloured as a whole instead, so their letters still join. TextCascade cascades only Latin text for the same reason. Native also has no cheap blur, so the web version's blur effects are left out. whileInView and startOnView aren't ported: animations start when mounted.
Copy this directory, hooks/use-presence.ts, hooks/use-reduced-motion.ts, primitives/cascade-text.tsx, primitives/glyph-row.tsx, styles/motion.ts, and the semantic tokens in styles/theme.css.