Kinetik UI

Word Cascade

.md

A paragraph that arrives word by word, each word rising into place after the last.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add word-cascade

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/word-cascade.tsx
    View source files

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 Text for the remainder.
  • Screen readers receive the whole paragraph at once.

Props

WordCascadeProps

PropTypeDefaultDescription
textrequiredstring—
staggernumber45Delay between consecutive words, in ms. Defaults to 45.
wordDurationnumberdurations.slowHow long each word takes to arrive, in ms. Defaults to the `slow` duration.
delaynumber0Wait before the first word, in ms. Defaults to 0.
risenumber10Distance each word rises, in pt. Defaults to 10.
replayKeystring | number—Change this value to replay.
animateOnMountbooleantruePlay on first render. Defaults to true.
onDone() => void—
styleStyleProp<TextStyle>—
containerStyleStyleProp<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 },
});