# Word Cascade

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

- Category: Text
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add word-cascade
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/word-cascade.json
```

## Usage

```tsx
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.

## WordCascadeProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `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>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/word-cascade.tsx`

```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 },
});
```
