# Scramble Text

> Characters cycle through random glyphs and lock into place from left to right.

- Category: Text
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: none
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add scramble-text
```

With the shadcn CLI:

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

## Usage

```tsx
import { ScrambleText } from '@/components/kinetik/scramble-text';

<ScrambleText text="KINETIK" style={{ fontSize: 48, fontWeight: '800' }} />;
```

The effect plays on mount and whenever `text` changes. Change `replayKey` to replay it with the same text.

## When to use

- Headlines, codes and status labels that change: a build number, a reveal, a mode switch.
- Monospace or uppercase text, where the cycling glyphs keep a steady rhythm.

## When not to use

- Paragraphs. Scrambled body text is unreadable and long strings wrap unpredictably while cycling.
- Text the user is trying to read while it changes, such as prices during checkout.

## Notes

- The settled text sizes the component, so scrambling never shifts the layout around it.
- While scrambling, one `Text` re-renders about 18 times a second; when every character has settled the timer stops. This is deliberate: driving a `TextInput`'s text from the UI thread every frame chains text-input states on Android's New Architecture and can crash a long-running screen.
- Screen readers get the final text immediately.

## ScrambleTextProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` |  | The text to settle on. Changing it scrambles again. |
| `duration`? | `number` | `900` | Time in ms for all characters to settle. Defaults to 900. |
| `delay`? | `number` | `0` | Wait before the first character settles, in ms. Defaults to 0. |
| `glyphs`? | `string` | `SCRAMBLE_GLYPHS` | Glyphs used while scrambling. |
| `replayKey`? | `string \| number` |  | Change this value to replay the effect with the same text. |
| `animateOnMount`? | `boolean` | `true` | Scramble on first render. Defaults to true. |
| `onDone`? | `() => void` |  | Called when every character has settled. |
| `style`? | `StyleProp<TextStyle>` |  |  |
| `containerStyle`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Shows the final text immediately.
- Screen readers: Exposed as a single text element with the final string; the scrambling glyphs are hidden.
- Touch target: Not interactive.

## Performance

Re-renders one Text at ~18 Hz for `duration` ms, then stops. Deliberately not driven per frame through a TextInput: on Android Fabric that chains text-input states and can crash.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory. Frames at rest in the measurement came from the demo cycling words; the effect stops once the text settles.
- Real-device measurement pending.

## Source

`components/kinetik/scramble-text.tsx`

```tsx
import { useEffect, useRef, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from 'react-native';

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

export const SCRAMBLE_GLYPHS = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$*+=';

/** Glyphs change every 55 ms: fast enough to read as noise, slow enough not to strobe. */
const TICK = 55;

export type ScrambleTextProps = {
  /** The text to settle on. Changing it scrambles again. */
  text: string;
  /** Time in ms for all characters to settle. Defaults to 900. */
  duration?: number;
  /** Wait before the first character settles, in ms. Defaults to 0. */
  delay?: number;
  /** Glyphs used while scrambling. */
  glyphs?: string;
  /** Change this value to replay the effect with the same text. */
  replayKey?: string | number;
  /** Scramble on first render. Defaults to true. */
  animateOnMount?: boolean;
  /** Called when every character has settled. */
  onDone?: () => void;
  style?: StyleProp<TextStyle>;
  containerStyle?: StyleProp<ViewStyle>;
};

/** Deterministic 0–1 jitter per character, so replays look the same. */
function jitter(i: number) {
  return ((i * 9301 + 49297) % 233280) / 233280;
}

/** The string shown `elapsed` ms into the effect, and whether it has settled. */
export function scrambleFrame(
  goal: string,
  elapsed: number,
  duration: number,
  delay: number,
  glyphs: string,
): { text: string; settled: boolean } {
  const n = goal.length;
  const tick = Math.floor(elapsed / TICK);
  let text = '';
  let settled = true;
  for (let i = 0; i < n; i++) {
    const ch = goal.charAt(i);
    if (ch === ' ' || ch === '\n') {
      text += ch;
      continue;
    }
    const at = delay + (n <= 1 ? 0 : i / (n - 1)) * duration * 0.65 + duration * 0.35 * jitter(i);
    if (elapsed >= at) {
      text += ch;
    } else {
      settled = false;
      text += glyphs.charAt((tick * 31 + i * 17) % glyphs.length);
    }
  }
  return { text, settled };
}

/**
 * Text whose characters cycle through random glyphs and lock into place from
 * left to right. Best for headlines, labels and codes rather than paragraphs.
 *
 * The glyphs step on a ~18 Hz timer for the length of the effect and then
 * stop. (Driving a TextInput's text from the UI thread every frame is avoided
 * on purpose: on Android's New Architecture each update chains a new text
 * input state onto the last, and long chains crash when released.)
 */
export function ScrambleText({
  text,
  duration = 900,
  delay = 0,
  glyphs = SCRAMBLE_GLYPHS,
  replayKey,
  animateOnMount = true,
  onDone,
  style,
  containerStyle,
}: ScrambleTextProps) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [shown, setShown] = useState(() =>
    animateOnMount && !reduced ? scrambleFrame(text, 0, duration, delay, glyphs).text : text,
  );
  const [box, setBox] = useState<{ width: number; height: number } | null>(null);
  // Show the first scrambled frame in the same render the text changes.
  const [played, setPlayed] = useState({ text, replayKey });
  if (played.text !== text || played.replayKey !== replayKey) {
    setPlayed({ text, replayKey });
    setShown(scrambleFrame(text, 0, duration, delay, glyphs).text);
  }
  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) return;
    const start = Date.now();
    const step = () => {
      const frame = scrambleFrame(text, Date.now() - start, duration, delay, glyphs);
      setShown(frame.text);
      if (frame.settled) {
        clearInterval(timer);
        onDoneRef.current?.();
      }
    };
    const timer = setInterval(step, TICK);
    return () => clearInterval(timer);
  }, [text, replayKey, reduced, animateOnMount, duration, delay, glyphs]);

  const textStyle: StyleProp<TextStyle> = [
    { color: colors.text, fontSize: font.size['2xl'], fontWeight: font.weight.bold },
    style,
  ];
  // Scramble glyphs can be wider than the settled text. Single-line text gets
  // headroom so a wide glyph never wraps; multi-line text keeps its exact width
  // so line breaks match the settled text.
  const multiline = text.includes('\n');

  return (
    <View
      accessible
      accessibilityRole="text"
      accessibilityLabel={text}
      style={[styles.container, containerStyle]}
    >
      {/* The settled text sizes the box so scrambling never shifts layout. */}
      <Text
        style={[textStyle, styles.ghost]}
        importantForAccessibility="no-hide-descendants"
        accessibilityElementsHidden
        onLayout={(e) => setBox(e.nativeEvent.layout)}
      >
        {text}
      </Text>
      <Text
        importantForAccessibility="no-hide-descendants"
        accessibilityElementsHidden
        numberOfLines={multiline ? undefined : 1}
        style={[
          textStyle,
          styles.overlay,
          box && {
            width: multiline ? box.width : Math.max(box.width * 1.5, box.width + 48),
          },
        ]}
      >
        {reduced ? text : shown}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { alignSelf: 'flex-start' },
  ghost: { opacity: 0 },
  overlay: { position: 'absolute', left: 0, top: 0 },
});
```
