# Word Rotator

> A line with one word that keeps changing, rolling out and in letter by letter while its gap springs to fit.

- 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-rotator
```

With the shadcn CLI:

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

## Usage

```tsx
import { WordRotator } from '@/components/kinetik/word-rotator';

<WordRotator
  before="Interfaces that feel"
  words={['alive.', 'effortless.', 'yours.']}
  align="center"
/>;
```

`style` sets the type for the whole line; `wordStyle` is laid on top for the rotating word, which is azure by default.

## When to use

- A hero line that says one thing several ways: what an app is for, who it is for.
- Short, punchy words, three to six of them.

## When not to use

- Words people need to act on. Anything that changes on its own is easy to miss.
- Long phrases. Use Typewriter, which reads better for whole sentences.

## Notes

- Every word is measured once, off screen, so the gap springs to the right width before the letters arrive.
- Pass `paused` while the line is off screen.
- Screen readers hear the line once with every word, not an announcement on every change.
- With reduced motion the words crossfade in place.

## WordRotatorProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` | `readonly string[]` |  | The words that take turns, e.g. ['alive', 'effortless', 'yours']. |
| `before`? | `string` |  | Fixed text before the rotating word. |
| `after`? | `string` |  | Fixed text after the rotating word. |
| `interval`? | `number` | `2400` | Time each word stays, in ms. Defaults to 2400. |
| `stagger`? | `number` | `24` | Delay between letters as a word rolls in, in ms. Defaults to 24. |
| `paused`? | `boolean` | `false` | Hold on the current word, e.g. while off screen. |
| `align`? | `'left' \| 'center'` | `'left'` | Line alignment. Defaults to `left`. |
| `style`? | `StyleProp<TextStyle>` |  | Style for all of the text. |
| `wordStyle`? | `StyleProp<TextStyle>` |  | Style for the rotating word, on top of `style`. Defaults to the theme azure. |
| `containerStyle`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Words crossfade in place; the gap still resizes.
- Screen readers: Reads the whole line once, with every word in turn, instead of announcing each change.
- Touch target: Not interactive.

## Performance

One clock drives every letter on the UI thread; the width is one spring. Words are measured once, off screen.

- Real-device measurement pending.

## Source

`components/kinetik/word-rotator.tsx`

```tsx
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type LayoutChangeEvent,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type WordRotatorProps = {
  /** The words that take turns, e.g. ['alive', 'effortless', 'yours']. */
  words: readonly string[];
  /** Fixed text before the rotating word. */
  before?: string;
  /** Fixed text after the rotating word. */
  after?: string;
  /** Time each word stays, in ms. Defaults to 2400. */
  interval?: number;
  /** Delay between letters as a word rolls in, in ms. Defaults to 24. */
  stagger?: number;
  /** Hold on the current word, e.g. while off screen. */
  paused?: boolean;
  /** Line alignment. Defaults to `left`. */
  align?: 'left' | 'center';
  /** Style for all of the text. */
  style?: StyleProp<TextStyle>;
  /** Style for the rotating word, on top of `style`. Defaults to the theme azure. */
  wordStyle?: StyleProp<TextStyle>;
  containerStyle?: StyleProp<ViewStyle>;
};

/** Spaces inside a word, kept from collapsing when letters are laid out one by one. */
const NBSP = String.fromCharCode(0xa0);
/** How long one letter takes to roll, in ms. */
const LETTER = 420;

/**
 * A line with one word that keeps changing: the old word rolls up and out
 * letter by letter while the new one rolls in from below, and the gap it
 * sits in springs to the new word's width.
 */
export function WordRotator({
  words,
  before,
  after,
  interval = 2400,
  stagger = 24,
  paused = false,
  align = 'left',
  style,
  wordStyle,
  containerStyle,
}: WordRotatorProps) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const n = words.length;
  const [turn, setTurn] = useState({ now: 0, was: -1 });
  const [widths, setWidths] = useState<number[]>([]);
  const [lineH, setLineH] = useState(0);

  const clock = useSharedValue(0);
  useEffect(() => {
    if (paused || n < 2) return;
    const id = setInterval(() => {
      // Reset before the new letters mount, so they never show a frame at rest.
      clock.set(0);
      setTurn((t) => ({ now: (t.now + 1) % n, was: t.now }));
    }, interval);
    return () => clearInterval(id);
  }, [paused, n, interval, clock]);

  const width = useSharedValue(0);
  const longest = Math.max(1, ...words.map((w) => w.length));
  const total = LETTER + (longest - 1) * stagger;

  useEffect(() => {
    if (turn.was < 0) return;
    clock.set(withTiming(total, { duration: reduced ? 1 : total, easing: Easing.linear }));
  }, [turn, total, reduced, clock]);

  const target = widths[turn.now] ?? 0;
  useEffect(() => {
    if (target === 0) return;
    // The first width is set, not animated.
    width.set(width.get() === 0 ? target : withSpring(target, spring('smooth', reduced)));
  }, [target, reduced, width]);

  const slot = useAnimatedStyle(() => (width.get() > 0 ? { width: width.get() } : {}));

  const measure = (i: number) => (e: LayoutChangeEvent) => {
    const w = Math.ceil(e.nativeEvent.layout.width);
    if (i === 0) setLineH(Math.ceil(e.nativeEvent.layout.height));
    setWidths((ws) => {
      if (ws[i] === w) return ws;
      const next = ws.slice();
      next[i] = w;
      return next;
    });
  };

  const text: StyleProp<TextStyle> = [
    {
      color: colors.text,
      fontSize: font.size['3xl'],
      fontWeight: font.weight.bold,
      letterSpacing: -0.8,
    },
    style,
  ];
  const accent: StyleProp<TextStyle> = [text, { color: colors.azure }, wordStyle];

  return (
    <View
      accessible
      accessibilityRole="text"
      accessibilityLabel={[before, words.join(', '), after].filter(Boolean).join(' ')}
      style={[styles.line, align === 'center' && styles.center, containerStyle]}
    >
      {/* Every word, laid out off screen, to know each one's width. */}
      <View
        style={styles.measure}
        pointerEvents="none"
        importantForAccessibility="no-hide-descendants"
        accessibilityElementsHidden
      >
        {/* Measured letter by letter, exactly as the word is drawn. */}
        {words.map((w, i) => (
          <View key={`${w}-${i}`} style={[styles.word, styles.measured]} onLayout={measure(i)}>
            {Array.from(w).map((ch, j) => (
              <Text key={j} style={accent}>
                {ch === ' ' ? NBSP : ch}
              </Text>
            ))}
          </View>
        ))}
      </View>

      {before ? (
        <Text style={text} importantForAccessibility="no" accessibilityElementsHidden>
          {`${before} `}
        </Text>
      ) : null}
      <Animated.View
        style={[styles.slot, lineH > 0 && { height: lineH }, slot]}
        importantForAccessibility="no-hide-descendants"
        accessibilityElementsHidden
      >
        {turn.was >= 0 ? (
          <Word
            key={`was-${turn.was}-${turn.now}`}
            word={words[turn.was] ?? ''}
            leaving
            clock={clock}
            stagger={stagger}
            height={lineH}
            reduced={reduced}
            style={accent}
          />
        ) : null}
        <Word
          key={`now-${turn.now}`}
          word={words[turn.now] ?? ''}
          leaving={false}
          clock={turn.was >= 0 ? clock : undefined}
          stagger={stagger}
          height={lineH}
          reduced={reduced}
          style={accent}
        />
      </Animated.View>
      {after ? (
        <Text style={text} importantForAccessibility="no" accessibilityElementsHidden>
          {` ${after}`}
        </Text>
      ) : null}
    </View>
  );
}

function Word({
  word,
  leaving,
  clock,
  stagger,
  height,
  reduced,
  style,
}: {
  word: string;
  leaving: boolean;
  /** Absent for the very first word, which simply sits in place. */
  clock?: SharedValue<number>;
  stagger: number;
  height: number;
  reduced: boolean;
  style: StyleProp<TextStyle>;
}) {
  return (
    <View style={[styles.word, leaving && styles.stacked]}>
      {Array.from(word).map((ch, i) => (
        <Letter
          key={i}
          ch={ch}
          start={i * stagger}
          leaving={leaving}
          clock={clock}
          height={height}
          reduced={reduced}
          style={style}
        />
      ))}
    </View>
  );
}

function Letter({
  ch,
  start,
  leaving,
  clock,
  height,
  reduced,
  style,
}: {
  ch: string;
  start: number;
  leaving: boolean;
  clock?: SharedValue<number>;
  height: number;
  reduced: boolean;
  style: StyleProp<TextStyle>;
}) {
  const animated = useAnimatedStyle(() => {
    if (!clock) return {};
    const p = Math.max(0, Math.min(1, (clock.get() - start) / LETTER));
    // Ease-out quart: letters snap into place and settle.
    const e = 1 - (1 - p) ** 4;
    if (reduced) return { opacity: leaving ? 1 - p : p };
    const travel = height * 0.8;
    return leaving
      ? { opacity: 1 - e, transform: [{ translateY: -travel * e }] }
      : { opacity: e, transform: [{ translateY: travel * (1 - e) }] };
  });
  return <Animated.Text style={[style, animated]}>{ch === ' ' ? NBSP : ch}</Animated.Text>;
}

const styles = StyleSheet.create({
  line: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-end' },
  center: { justifyContent: 'center' },
  measure: { position: 'absolute', opacity: 0, left: 0, top: 0, width: 2000 },
  measured: { position: 'absolute', left: 0, top: 0 },
  slot: { overflow: 'hidden' },
  word: { flexDirection: 'row' },
  stacked: { position: 'absolute', left: 0, top: 0 },
});
```
