Kinetik UI

Scramble Text

.md

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

v1.0.0Live on web
Component preview

Installation

Kinetik CLI
npx kinetik-ui add scramble-text

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/scramble-text.json

Install manually

  1. Copy the source files
    • components/kinetik/scramble-text.tsx
    View source files

Usage

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.

Props

ScrambleTextProps

PropTypeDefaultDescription
textrequiredstring—The text to settle on. Changing it scrambles again.
durationnumber900Time in ms for all characters to settle. Defaults to 900.
delaynumber0Wait before the first character settles, in ms. Defaults to 0.
glyphsstringSCRAMBLE_GLYPHSGlyphs used while scrambling.
replayKeystring | number—Change this value to replay the effect with the same text.
animateOnMountbooleantrueScramble on first render. Defaults to true.
onDone() => void—Called when every character has settled.
styleStyleProp<TextStyle>—
containerStyleStyleProp<ViewStyle>—

Performance and accessibility

Performance

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

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.

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/scramble-text.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 },
});