Scramble Text
Characters cycle through random glyphs and lock into place from left to right.
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 },
});Installation
npx kinetik-ui add scramble-textpnpm dlx kinetik-ui add scramble-textbunx kinetik-ui add scramble-textyarn dlx kinetik-ui add scramble-textThe 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
- Copy the source files
components/kinetik/scramble-text.tsx
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
Textre-renders about 18 times a second; when every character has settled the timer stops. This is deliberate: driving aTextInput'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
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | 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> | — |
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 },
});