Letter Morph
A label that morphs letter by letter: shared letters slide to new places as others float away or rise in.
import { useEffect, useState } from 'react';
import {
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type LetterMorphProps = {
/** The text. Change it and shared letters slide to their new places. */
text: string;
/** Style for the letters. Defaults to a large, semibold line in the theme text colour. */
textStyle?: StyleProp<TextStyle>;
/** Read by screen readers. Defaults to the text. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
type Glyph = { ch: string; key: string };
type Leaving = Glyph & { x: number };
const NBSP = String.fromCharCode(0xa0);
/**
* Gives every letter of `next` a key, reusing the key of the same letter in
* `prev` when one is still to come, so shared letters keep their identity and
* the rest enter or leave.
*/
export function matchGlyphs(prev: readonly Glyph[], next: string, makeKey: () => string): Glyph[] {
const used = new Array<boolean>(prev.length).fill(false);
let from = 0;
return Array.from(next).map((ch) => {
for (let k = from; k < prev.length; k++) {
if (!used[k] && prev[k]!.ch === ch) {
used[k] = true;
from = k + 1;
return { ch, key: prev[k]!.key };
}
}
return { ch, key: makeKey() };
});
}
/**
* A label that morphs into its next text letter by letter. Letters the two
* words share slide to their new places, the ones that go float up and fade,
* and new ones rise in where they belong: Save becomes Saved, Follow becomes
* Following.
*
* Each distinct character is measured once; after that every letter moves by
* transform on the UI thread, so a change never lays the screen out again
* frame by frame.
*/
export function LetterMorph({
text,
textStyle,
accessibilityLabel,
testID,
style,
}: LetterMorphProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const letter = StyleSheet.flatten([
{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.semibold,
letterSpacing: font.tracking.tight,
},
textStyle,
]);
const styleKey = JSON.stringify(letter);
// Widths of the characters seen so far, for the current text style.
const [metrics, setMetrics] = useState<{
styleKey: string;
widths: Record<string, number>;
height: number;
}>({ styleKey, widths: {}, height: 0 });
if (metrics.styleKey !== styleKey) setMetrics({ styleKey, widths: {}, height: 0 });
const { widths } = metrics;
const place = (glyphs: readonly Glyph[]) => {
let x = 0;
return glyphs.map((g) => {
const at = x;
x += widths[g.ch] ?? 0;
return at;
});
};
// Keys for the current text, worked out again (from the previous keys) when it
// changes. Letters that are not kept leave from where they were.
const [state, setState] = useState(() => {
let n = 0;
return {
text,
glyphs: matchGlyphs([], text, () => `g${n++}`),
next: n,
leaving: [] as Leaving[],
batch: 0,
};
});
if (state.text !== text) {
let n = state.next;
const glyphs = matchGlyphs(state.glyphs, text, () => `g${n++}`);
const kept = new Set(glyphs.map((g) => g.key));
const before = place(state.glyphs);
const leaving = reduced
? []
: state.glyphs.map((g, i) => ({ ...g, x: before[i]! })).filter((g) => !kept.has(g.key));
setState({ text, glyphs, next: n, leaving, batch: state.batch + 1 });
}
// Leaving letters are dropped once they have faded.
const { batch } = state;
const leavingCount = state.leaving.length;
useEffect(() => {
if (leavingCount === 0) return;
const id = setTimeout(
() => setState((s) => (s.batch === batch ? { ...s, leaving: [] } : s)),
durations.base,
);
return () => clearTimeout(id);
}, [batch, leavingCount]);
const missing = [...new Set(Array.from(text))].filter((ch) => widths[ch] === undefined);
const ready = missing.length === 0;
const xs = place(state.glyphs);
const last = state.glyphs[state.glyphs.length - 1];
const total = ready && last ? xs[xs.length - 1]! + (widths[last.ch] ?? 0) : 0;
const width = useSharedValue(total);
const sized = useSharedValue(false);
useEffect(() => {
if (!ready) return;
width.set(!sized.get() || reduced ? total : withSpring(total, spring('smooth')));
sized.set(true);
}, [ready, total, reduced, width, sized]);
const box = useAnimatedStyle(() => ({ width: width.get() }));
// Letters on screen when the component mounted appear without animating in.
const [firstKeys] = useState(() => new Set(state.glyphs.map((g) => g.key)));
return (
<View
testID={testID}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? text}
style={style}
>
{/* Measures characters not seen before, out of sight. */}
{missing.length > 0 ? (
<View
style={styles.measure}
pointerEvents="none"
importantForAccessibility="no-hide-descendants"
>
{missing.map((ch) => (
<Text
key={ch}
style={letter}
onLayout={(e) => {
const { width: w, height: h } = e.nativeEvent.layout;
setMetrics((m) =>
m.styleKey !== styleKey || m.widths[ch] !== undefined
? m
: { ...m, widths: { ...m.widths, [ch]: w }, height: Math.max(m.height, h) },
);
}}
>
{ch === ' ' ? NBSP : ch}
</Text>
))}
</View>
) : null}
<Animated.View style={[{ height: metrics.height || undefined }, box]}>
{ready
? [
...state.leaving.map((g) => (
<Letter
key={`${g.key}-out`}
ch={g.ch}
x={g.x}
leave
animateIn={false}
reduced={reduced}
style={letter}
/>
)),
...state.glyphs.map((g, i) => (
<Letter
key={g.key}
ch={g.ch}
x={xs[i]!}
leave={false}
animateIn={!firstKeys.has(g.key)}
reduced={reduced}
style={letter}
/>
)),
]
: null}
</Animated.View>
</View>
);
}
type LetterProps = {
ch: string;
x: number;
leave: boolean;
animateIn: boolean;
reduced: boolean;
style: TextStyle;
};
function Letter({ ch, x, leave, animateIn, reduced, style }: LetterProps) {
const tx = useSharedValue(x);
const shown = useSharedValue(leave || !animateIn || reduced ? 1 : 0);
useEffect(() => {
tx.set(reduced ? x : withSpring(x, spring('smooth')));
}, [x, reduced, tx]);
useEffect(() => {
if (leave) {
shown.set(withTiming(0, { duration: durations.quick, easing: easings.exit }));
} else if (shown.get() < 1) {
shown.set(
withDelay(60, withTiming(1, { duration: durations.base, easing: easings.standard })),
);
}
}, [leave, shown]);
const motion = useAnimatedStyle(() => {
const p = shown.get();
// New letters rise into place; leaving ones keep rising as they fade.
const dy = leave ? -(1 - p) * 10 : (1 - p) * 10;
return { opacity: p, transform: [{ translateX: tx.get() }, { translateY: dy }] };
});
return (
<Animated.Text
style={[style, styles.letter, motion]}
accessibilityElementsHidden
importantForAccessibility="no"
>
{ch === ' ' ? NBSP : ch}
</Animated.Text>
);
}
const styles = StyleSheet.create({
measure: { position: 'absolute', opacity: 0, flexDirection: 'row', left: 0, top: 0 },
letter: { position: 'absolute', left: 0, top: 0 },
});Installation
npx kinetik-ui add letter-morphpnpm dlx kinetik-ui add letter-morphbunx kinetik-ui add letter-morphyarn dlx kinetik-ui add letter-morphThe 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/letter-morph.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/letter-morph.tsx
Usage
import { LetterMorph } from '@/components/kinetik/letter-morph';
const label = following ? 'Following' : 'Follow';
<Pressable accessibilityRole="button" accessibilityLabel={label} onPress={toggle}>
<LetterMorph text={label} textStyle={{ fontSize: 16 }} />
</Pressable>;Change text and the letters the old and new text share slide to their new places, while the others float away or rise in.
When to use
- Labels that change state in place: Follow to Following, Save to Saved, Add to Added.
- Short headlines that cycle through related words.
When not to use
- Long text or paragraphs. Each letter is its own view.
- Numbers that count. Use Odometer.
Notes
- Letters are matched in order, so a letter is never pulled across another to reach its new place.
- Each letter is drawn separately, so kerning pairs are not applied. It is rarely visible at label sizes; check it with your typeface at large sizes.
- The label is read as one piece of text; the letters are hidden from screen readers. Inside a button, give the button the same
accessibilityLabel. - With reduced motion the new text replaces the old at once.
Props
LetterMorphProps
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | string | — | The text. Change it and shared letters slide to their new places. |
textStyle | StyleProp<TextStyle> | — | Style for the letters. Defaults to a large, semibold line in the theme text colour. |
accessibilityLabel | string | — | Read by screen readers. Defaults to the text. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One text view per letter, moved by transforms on the UI thread; each new character is measured once. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.90× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 161 MB app memory. The demo changes the headline every 1.8 s, so it draws at rest; the long p99 frame is the first appearance of a new character, which is measured once.
Real-device measurement pending.
Accessibility
- Reduced motion
- The new text replaces the old at once.
- Screen readers
- One text element that reads the whole label; the letters themselves are hidden.
- Touch target
- Not interactive itself; put it inside a button for actions.
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/letter-morph.tsx
import { useEffect, useState } from 'react';
import {
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type LetterMorphProps = {
/** The text. Change it and shared letters slide to their new places. */
text: string;
/** Style for the letters. Defaults to a large, semibold line in the theme text colour. */
textStyle?: StyleProp<TextStyle>;
/** Read by screen readers. Defaults to the text. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
type Glyph = { ch: string; key: string };
type Leaving = Glyph & { x: number };
const NBSP = String.fromCharCode(0xa0);
/**
* Gives every letter of `next` a key, reusing the key of the same letter in
* `prev` when one is still to come, so shared letters keep their identity and
* the rest enter or leave.
*/
export function matchGlyphs(prev: readonly Glyph[], next: string, makeKey: () => string): Glyph[] {
const used = new Array<boolean>(prev.length).fill(false);
let from = 0;
return Array.from(next).map((ch) => {
for (let k = from; k < prev.length; k++) {
if (!used[k] && prev[k]!.ch === ch) {
used[k] = true;
from = k + 1;
return { ch, key: prev[k]!.key };
}
}
return { ch, key: makeKey() };
});
}
/**
* A label that morphs into its next text letter by letter. Letters the two
* words share slide to their new places, the ones that go float up and fade,
* and new ones rise in where they belong: Save becomes Saved, Follow becomes
* Following.
*
* Each distinct character is measured once; after that every letter moves by
* transform on the UI thread, so a change never lays the screen out again
* frame by frame.
*/
export function LetterMorph({
text,
textStyle,
accessibilityLabel,
testID,
style,
}: LetterMorphProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const letter = StyleSheet.flatten([
{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.semibold,
letterSpacing: font.tracking.tight,
},
textStyle,
]);
const styleKey = JSON.stringify(letter);
// Widths of the characters seen so far, for the current text style.
const [metrics, setMetrics] = useState<{
styleKey: string;
widths: Record<string, number>;
height: number;
}>({ styleKey, widths: {}, height: 0 });
if (metrics.styleKey !== styleKey) setMetrics({ styleKey, widths: {}, height: 0 });
const { widths } = metrics;
const place = (glyphs: readonly Glyph[]) => {
let x = 0;
return glyphs.map((g) => {
const at = x;
x += widths[g.ch] ?? 0;
return at;
});
};
// Keys for the current text, worked out again (from the previous keys) when it
// changes. Letters that are not kept leave from where they were.
const [state, setState] = useState(() => {
let n = 0;
return {
text,
glyphs: matchGlyphs([], text, () => `g${n++}`),
next: n,
leaving: [] as Leaving[],
batch: 0,
};
});
if (state.text !== text) {
let n = state.next;
const glyphs = matchGlyphs(state.glyphs, text, () => `g${n++}`);
const kept = new Set(glyphs.map((g) => g.key));
const before = place(state.glyphs);
const leaving = reduced
? []
: state.glyphs.map((g, i) => ({ ...g, x: before[i]! })).filter((g) => !kept.has(g.key));
setState({ text, glyphs, next: n, leaving, batch: state.batch + 1 });
}
// Leaving letters are dropped once they have faded.
const { batch } = state;
const leavingCount = state.leaving.length;
useEffect(() => {
if (leavingCount === 0) return;
const id = setTimeout(
() => setState((s) => (s.batch === batch ? { ...s, leaving: [] } : s)),
durations.base,
);
return () => clearTimeout(id);
}, [batch, leavingCount]);
const missing = [...new Set(Array.from(text))].filter((ch) => widths[ch] === undefined);
const ready = missing.length === 0;
const xs = place(state.glyphs);
const last = state.glyphs[state.glyphs.length - 1];
const total = ready && last ? xs[xs.length - 1]! + (widths[last.ch] ?? 0) : 0;
const width = useSharedValue(total);
const sized = useSharedValue(false);
useEffect(() => {
if (!ready) return;
width.set(!sized.get() || reduced ? total : withSpring(total, spring('smooth')));
sized.set(true);
}, [ready, total, reduced, width, sized]);
const box = useAnimatedStyle(() => ({ width: width.get() }));
// Letters on screen when the component mounted appear without animating in.
const [firstKeys] = useState(() => new Set(state.glyphs.map((g) => g.key)));
return (
<View
testID={testID}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? text}
style={style}
>
{/* Measures characters not seen before, out of sight. */}
{missing.length > 0 ? (
<View
style={styles.measure}
pointerEvents="none"
importantForAccessibility="no-hide-descendants"
>
{missing.map((ch) => (
<Text
key={ch}
style={letter}
onLayout={(e) => {
const { width: w, height: h } = e.nativeEvent.layout;
setMetrics((m) =>
m.styleKey !== styleKey || m.widths[ch] !== undefined
? m
: { ...m, widths: { ...m.widths, [ch]: w }, height: Math.max(m.height, h) },
);
}}
>
{ch === ' ' ? NBSP : ch}
</Text>
))}
</View>
) : null}
<Animated.View style={[{ height: metrics.height || undefined }, box]}>
{ready
? [
...state.leaving.map((g) => (
<Letter
key={`${g.key}-out`}
ch={g.ch}
x={g.x}
leave
animateIn={false}
reduced={reduced}
style={letter}
/>
)),
...state.glyphs.map((g, i) => (
<Letter
key={g.key}
ch={g.ch}
x={xs[i]!}
leave={false}
animateIn={!firstKeys.has(g.key)}
reduced={reduced}
style={letter}
/>
)),
]
: null}
</Animated.View>
</View>
);
}
type LetterProps = {
ch: string;
x: number;
leave: boolean;
animateIn: boolean;
reduced: boolean;
style: TextStyle;
};
function Letter({ ch, x, leave, animateIn, reduced, style }: LetterProps) {
const tx = useSharedValue(x);
const shown = useSharedValue(leave || !animateIn || reduced ? 1 : 0);
useEffect(() => {
tx.set(reduced ? x : withSpring(x, spring('smooth')));
}, [x, reduced, tx]);
useEffect(() => {
if (leave) {
shown.set(withTiming(0, { duration: durations.quick, easing: easings.exit }));
} else if (shown.get() < 1) {
shown.set(
withDelay(60, withTiming(1, { duration: durations.base, easing: easings.standard })),
);
}
}, [leave, shown]);
const motion = useAnimatedStyle(() => {
const p = shown.get();
// New letters rise into place; leaving ones keep rising as they fade.
const dy = leave ? -(1 - p) * 10 : (1 - p) * 10;
return { opacity: p, transform: [{ translateX: tx.get() }, { translateY: dy }] };
});
return (
<Animated.Text
style={[style, styles.letter, motion]}
accessibilityElementsHidden
importantForAccessibility="no"
>
{ch === ' ' ? NBSP : ch}
</Animated.Text>
);
}
const styles = StyleSheet.create({
measure: { position: 'absolute', opacity: 0, flexDirection: 'row', left: 0, top: 0 },
letter: { position: 'absolute', left: 0, top: 0 },
});