Word Cascade
A paragraph that arrives word by word, each word rising into place after the last.
import { useEffect, useMemo, useRef } from 'react';
import { StyleSheet, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type WordCascadeProps = {
text: string;
/** Delay between consecutive words, in ms. Defaults to 45. */
stagger?: number;
/** How long each word takes to arrive, in ms. Defaults to the `slow` duration. */
wordDuration?: number;
/** Wait before the first word, in ms. Defaults to 0. */
delay?: number;
/** Distance each word rises, in pt. Defaults to 10. */
rise?: number;
/** Change this value to replay. */
replayKey?: string | number;
/** Play on first render. Defaults to true. */
animateOnMount?: boolean;
onDone?: () => void;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
/**
* A paragraph that arrives word by word: each word rises a few points and
* fades in, slightly after the one before it. One clock drives every word.
*/
export function WordCascade({
text,
stagger = 45,
wordDuration = durations.slow,
delay = 0,
rise = 10,
replayKey,
animateOnMount = true,
onDone,
style,
containerStyle,
}: WordCascadeProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
// Split on spaces but keep line breaks as their own tokens.
const words = useMemo(() => text.split(/(\n)| +/).filter((w): w is string => !!w), [text]);
const total = delay + Math.max(0, words.length - 1) * stagger + wordDuration;
const clock = useSharedValue(animateOnMount && !reduced ? 0 : total);
const first = useRef(true);
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
useEffect(() => {
const skip = reduced || (first.current && !animateOnMount);
first.current = false;
if (skip) {
clock.set(total);
return;
}
const finished = () => onDoneRef.current?.();
clock.set(0);
clock.set(
withTiming(total, { duration: total, easing: Easing.linear }, (done) => {
'worklet';
if (done) scheduleOnRN(finished);
}),
);
// Replays when the text or replayKey changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, replayKey, reduced]);
const textStyle: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size.lg,
lineHeight: font.size.lg * font.lineHeight.relaxed,
},
style,
];
let index = 0;
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
style={[styles.flow, containerStyle]}
>
{words.map((word, i) => {
if (word === '\n') return <View key={`br${i}`} style={styles.lineBreak} />;
const at = delay + index++ * stagger;
return (
<Word
key={`${i}-${word}`}
word={word}
start={at}
length={wordDuration}
rise={rise}
clock={clock}
style={textStyle}
/>
);
})}
</View>
);
}
function Word({
word,
start,
length,
rise,
clock,
style,
}: {
word: string;
start: number;
length: number;
rise: number;
clock: SharedValue<number>;
style: StyleProp<TextStyle>;
}) {
const animated = useAnimatedStyle(() => {
const p = Math.min(1, Math.max(0, (clock.get() - start) / length));
// Ease-out cubic: words decelerate into place.
const e = 1 - (1 - p) * (1 - p) * (1 - p);
return { opacity: e, transform: [{ translateY: (1 - e) * rise }] };
});
return (
<Animated.Text
style={[style, animated]}
importantForAccessibility="no"
accessibilityElementsHidden
>
{word}{' '}
</Animated.Text>
);
}
const styles = StyleSheet.create({
flow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline' },
lineBreak: { width: '100%', height: 0 },
});Installation
npx kinetik-ui add word-cascadepnpm dlx kinetik-ui add word-cascadebunx kinetik-ui add word-cascadeyarn dlx kinetik-ui add word-cascadeThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/word-cascade.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/word-cascade.tsx
Usage
import { WordCascade } from '@/components/kinetik/word-cascade';
<WordCascade text="Motion should explain, not decorate." style={{ fontSize: 28 }} />;It plays on mount and whenever text or replayKey changes. Line breaks (\n) in the text are kept.
When to use
- A headline or short intro that should feel composed rather than dumped on screen: onboarding, empty states, section openers.
- Text that appears in response to an action, such as an AI answer or a result summary.
When not to use
- Body copy the user needs right away. A long cascade makes people wait to read.
- Lists, labels and buttons. Animate the container instead.
Notes
- One shared clock drives every word, so a long paragraph costs one timing animation, not one per word.
- Words wrap with flexbox; for very long texts consider a plain
Textfor the remainder. - Screen readers receive the whole paragraph at once.
Props
WordCascadeProps
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | string | — | |
stagger | number | 45 | Delay between consecutive words, in ms. Defaults to 45. |
wordDuration | number | durations.slow | How long each word takes to arrive, in ms. Defaults to the `slow` duration. |
delay | number | 0 | Wait before the first word, in ms. Defaults to 0. |
rise | number | 10 | Distance each word rises, in pt. Defaults to 10. |
replayKey | string | number | — | Change this value to replay. |
animateOnMount | boolean | true | Play on first render. Defaults to true. |
onDone | () => void | — | |
style | StyleProp<TextStyle> | — | |
containerStyle | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One linear timing drives every word; each word evaluates a small style worklet per frame until the cascade ends.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.12× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 146 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The whole paragraph appears at once.
- Screen readers
- Exposed as one text element with the full paragraph; individual words are hidden.
- Touch target
- Not interactive.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/word-cascade.tsx
import { useEffect, useMemo, useRef } from 'react';
import { StyleSheet, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type WordCascadeProps = {
text: string;
/** Delay between consecutive words, in ms. Defaults to 45. */
stagger?: number;
/** How long each word takes to arrive, in ms. Defaults to the `slow` duration. */
wordDuration?: number;
/** Wait before the first word, in ms. Defaults to 0. */
delay?: number;
/** Distance each word rises, in pt. Defaults to 10. */
rise?: number;
/** Change this value to replay. */
replayKey?: string | number;
/** Play on first render. Defaults to true. */
animateOnMount?: boolean;
onDone?: () => void;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
/**
* A paragraph that arrives word by word: each word rises a few points and
* fades in, slightly after the one before it. One clock drives every word.
*/
export function WordCascade({
text,
stagger = 45,
wordDuration = durations.slow,
delay = 0,
rise = 10,
replayKey,
animateOnMount = true,
onDone,
style,
containerStyle,
}: WordCascadeProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
// Split on spaces but keep line breaks as their own tokens.
const words = useMemo(() => text.split(/(\n)| +/).filter((w): w is string => !!w), [text]);
const total = delay + Math.max(0, words.length - 1) * stagger + wordDuration;
const clock = useSharedValue(animateOnMount && !reduced ? 0 : total);
const first = useRef(true);
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
useEffect(() => {
const skip = reduced || (first.current && !animateOnMount);
first.current = false;
if (skip) {
clock.set(total);
return;
}
const finished = () => onDoneRef.current?.();
clock.set(0);
clock.set(
withTiming(total, { duration: total, easing: Easing.linear }, (done) => {
'worklet';
if (done) scheduleOnRN(finished);
}),
);
// Replays when the text or replayKey changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, replayKey, reduced]);
const textStyle: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size.lg,
lineHeight: font.size.lg * font.lineHeight.relaxed,
},
style,
];
let index = 0;
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
style={[styles.flow, containerStyle]}
>
{words.map((word, i) => {
if (word === '\n') return <View key={`br${i}`} style={styles.lineBreak} />;
const at = delay + index++ * stagger;
return (
<Word
key={`${i}-${word}`}
word={word}
start={at}
length={wordDuration}
rise={rise}
clock={clock}
style={textStyle}
/>
);
})}
</View>
);
}
function Word({
word,
start,
length,
rise,
clock,
style,
}: {
word: string;
start: number;
length: number;
rise: number;
clock: SharedValue<number>;
style: StyleProp<TextStyle>;
}) {
const animated = useAnimatedStyle(() => {
const p = Math.min(1, Math.max(0, (clock.get() - start) / length));
// Ease-out cubic: words decelerate into place.
const e = 1 - (1 - p) * (1 - p) * (1 - p);
return { opacity: e, transform: [{ translateY: (1 - e) * rise }] };
});
return (
<Animated.Text
style={[style, animated]}
importantForAccessibility="no"
accessibilityElementsHidden
>
{word}{' '}
</Animated.Text>
);
}
const styles = StyleSheet.create({
flow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline' },
lineBreak: { width: '100%', height: 0 },
});