Typewriter
Text typed out with a human rhythm; several phrases are typed, held, erased and replaced in turn.
import { useEffect, useRef, useState } from 'react';
import { 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 type TypewriterProps = {
/** One phrase, or several typed in turn. */
phrases: string | readonly string[];
/** Fixed text before the typed part, e.g. "Find your way to ". */
prefix?: string;
/** Average time per character while typing, in ms. Defaults to 55. */
speed?: number;
/** Time per character while erasing, in ms. Defaults to 26. */
eraseSpeed?: number;
/** How long a finished phrase stays before it is erased, in ms. Defaults to 1800. */
hold?: number;
/** Go back to the first phrase after the last. Defaults to true. */
loop?: boolean;
/** Caret shape. Defaults to `bar`. */
caret?: 'bar' | 'block' | 'underscore' | 'none';
/** Caret colour. Defaults to the theme azure. */
caretColor?: string;
/** Called with a phrase's index once it is fully typed. */
onPhraseTyped?: (index: number) => void;
style?: StyleProp<TextStyle>;
/** Style of the prefix, on top of `style`. */
prefixStyle?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
type Mode = 'typing' | 'holding' | 'erasing' | 'done';
const CARETS = { bar: '|', block: '▍', underscore: '_', none: '' } as const;
/** Caret blink half-period, in ms. */
const BLINK = 530;
/**
* Text typed out a character at a time with a human rhythm: a little uneven,
* pausing after punctuation. Several phrases are typed, held, erased and
* replaced in turn. The caret stays solid while typing and blinks at rest.
*/
export function Typewriter({
phrases,
prefix,
speed = 55,
eraseSpeed = 26,
hold = 1800,
loop = true,
caret = 'bar',
caretColor,
onPhraseTyped,
style,
prefixStyle,
containerStyle,
}: TypewriterProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const list = typeof phrases === 'string' ? [phrases] : phrases;
const signature = list.join('\u0000');
const [state, setState] = useState<{ phrase: number; count: number; mode: Mode }>(() => ({
phrase: 0,
count: reduced ? (list[0]?.length ?? 0) : 0,
mode: reduced ? 'holding' : 'typing',
}));
const [blinkOn, setBlinkOn] = useState(true);
const typed = useRef(onPhraseTyped);
useEffect(() => {
typed.current = onPhraseTyped;
}, [onPhraseTyped]);
// A new set of phrases starts over.
const [seen, setSeen] = useState(signature);
if (seen !== signature) {
setSeen(signature);
setState({
phrase: 0,
count: reduced ? (list[0]?.length ?? 0) : 0,
mode: reduced ? 'holding' : 'typing',
});
}
const text = list[state.phrase] ?? '';
const many = list.length > 1;
// One step per timeout: type or erase a character, or move between phrases.
useEffect(() => {
const { phrase, count, mode } = state;
if (mode === 'done') return;
const last = phrase === list.length - 1;
let wait = 0;
let next: typeof state;
if (mode === 'typing' && count < text.length) {
// Uneven like a person: quicker inside words, a beat after spaces and punctuation.
const ch = text[count - 1] ?? '';
const pause = /[.,!?;:]/.test(ch) ? 5 : ch === ' ' ? 1.6 : 1;
wait = speed * pause * (0.6 + Math.random() * 0.8);
next = { phrase, count: count + 1, mode };
} else if (mode === 'typing') {
typed.current?.(phrase);
next = { phrase, count, mode: many && (loop || !last) ? 'holding' : 'done' };
} else if (mode === 'holding') {
wait = hold;
const after = (phrase + 1) % list.length;
if (!many || (!loop && last)) next = { phrase, count, mode: 'done' };
// With reduced motion phrases swap whole instead of being erased and retyped.
else if (reduced) next = { phrase: after, count: list[after]?.length ?? 0, mode };
else next = { phrase, count, mode: 'erasing' };
} else if (count > 0) {
wait = eraseSpeed;
next = { phrase, count: count - 1, mode };
} else {
next = { phrase: (phrase + 1) % list.length, count: 0, mode: 'typing' };
}
const id = setTimeout(() => setState(next), wait);
return () => clearTimeout(id);
// `list` is represented by `signature`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, signature, speed, eraseSpeed, hold, loop, reduced]);
// The caret blinks only at rest; while the text moves it stays lit, like a real editor.
const resting = state.mode === 'holding' || state.mode === 'done';
useEffect(() => {
if (!resting || caret === 'none') return;
const id = setInterval(() => setBlinkOn((on) => !on), BLINK);
return () => clearInterval(id);
}, [resting, caret]);
const caretShown = !resting || blinkOn;
const base: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: -0.4,
},
style,
];
return (
<View
accessible
accessibilityRole="text"
// Screen readers get the whole phrase, never a half-typed one.
accessibilityLabel={`${prefix ?? ''}${text}`}
style={containerStyle}
>
<Text
style={base}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
{prefix ? <Text style={prefixStyle}>{prefix}</Text> : null}
{text.slice(0, state.count)}
{caret !== 'none' ? (
<Text
style={{
color: caretColor ?? colors.azure,
opacity: caretShown ? 1 : 0,
fontWeight: caret === 'bar' ? font.weight.regular : undefined,
}}
>
{CARETS[caret]}
</Text>
) : null}
</Text>
</View>
);
}Installation
npx kinetik-ui add typewriterpnpm dlx kinetik-ui add typewriterbunx kinetik-ui add typewriteryarn dlx kinetik-ui add typewriterThe 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/typewriter.json
Install manually
- Copy the source files
components/kinetik/typewriter.tsx
Usage
import { Typewriter } from '@/components/kinetik/typewriter';
<Typewriter
prefix="Find your way to "
phrases={['Huacachina.', 'White Sands.', 'the edge of Fiji.']}
/>;A single string is typed once and then rests with a blinking caret. Several phrases are typed, held for hold ms, erased and replaced; pass loop={false} to stop on the last one.
When to use
- Hero headlines that show a range of things in one line: destinations, use cases, audiences.
- Assistant or terminal-like moments where text arriving is part of the story.
When not to use
- Anything people must read in full right away: instructions, errors, prices.
- Long paragraphs. Typing them out only makes people wait; use Word Cascade.
Notes
- Typing is uneven on purpose, with a beat after spaces and a longer one after punctuation.
- The caret stays solid while text is moving and blinks only at rest, as in an editor.
- The line grows as it types; give it room (or a
minHeight) so the layout below does not jump. - With reduced motion whole phrases swap after each hold.
Props
TypewriterProps
| Prop | Type | Default | Description |
|---|---|---|---|
phrasesrequired | string | readonly string[] | — | One phrase, or several typed in turn. |
prefix | string | — | Fixed text before the typed part, e.g. "Find your way to ". |
speed | number | 55 | Average time per character while typing, in ms. Defaults to 55. |
eraseSpeed | number | 26 | Time per character while erasing, in ms. Defaults to 26. |
hold | number | 1800 | How long a finished phrase stays before it is erased, in ms. Defaults to 1800. |
loop | boolean | true | Go back to the first phrase after the last. Defaults to true. |
caret | 'bar' | 'block' | 'underscore' | 'none' | 'bar' | Caret shape. Defaults to `bar`. |
caretColor | string | — | Caret colour. Defaults to the theme azure. |
onPhraseTyped | (index: number) => void | — | Called with a phrase's index once it is fully typed. |
style | StyleProp<TextStyle> | — | |
prefixStyle | StyleProp<TextStyle> | — | Style of the prefix, on top of `style`. |
containerStyle | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One timeout per character and a slow caret blink at rest. Plain text, so it wraps and scales like any other.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Whole phrases appear and swap after each hold; the caret still blinks at rest.
- Screen readers
- Reads as text with the whole current phrase, never a half-typed one.
- 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/typewriter.tsx
import { useEffect, useRef, useState } from 'react';
import { 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 type TypewriterProps = {
/** One phrase, or several typed in turn. */
phrases: string | readonly string[];
/** Fixed text before the typed part, e.g. "Find your way to ". */
prefix?: string;
/** Average time per character while typing, in ms. Defaults to 55. */
speed?: number;
/** Time per character while erasing, in ms. Defaults to 26. */
eraseSpeed?: number;
/** How long a finished phrase stays before it is erased, in ms. Defaults to 1800. */
hold?: number;
/** Go back to the first phrase after the last. Defaults to true. */
loop?: boolean;
/** Caret shape. Defaults to `bar`. */
caret?: 'bar' | 'block' | 'underscore' | 'none';
/** Caret colour. Defaults to the theme azure. */
caretColor?: string;
/** Called with a phrase's index once it is fully typed. */
onPhraseTyped?: (index: number) => void;
style?: StyleProp<TextStyle>;
/** Style of the prefix, on top of `style`. */
prefixStyle?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
type Mode = 'typing' | 'holding' | 'erasing' | 'done';
const CARETS = { bar: '|', block: '▍', underscore: '_', none: '' } as const;
/** Caret blink half-period, in ms. */
const BLINK = 530;
/**
* Text typed out a character at a time with a human rhythm: a little uneven,
* pausing after punctuation. Several phrases are typed, held, erased and
* replaced in turn. The caret stays solid while typing and blinks at rest.
*/
export function Typewriter({
phrases,
prefix,
speed = 55,
eraseSpeed = 26,
hold = 1800,
loop = true,
caret = 'bar',
caretColor,
onPhraseTyped,
style,
prefixStyle,
containerStyle,
}: TypewriterProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const list = typeof phrases === 'string' ? [phrases] : phrases;
const signature = list.join('\u0000');
const [state, setState] = useState<{ phrase: number; count: number; mode: Mode }>(() => ({
phrase: 0,
count: reduced ? (list[0]?.length ?? 0) : 0,
mode: reduced ? 'holding' : 'typing',
}));
const [blinkOn, setBlinkOn] = useState(true);
const typed = useRef(onPhraseTyped);
useEffect(() => {
typed.current = onPhraseTyped;
}, [onPhraseTyped]);
// A new set of phrases starts over.
const [seen, setSeen] = useState(signature);
if (seen !== signature) {
setSeen(signature);
setState({
phrase: 0,
count: reduced ? (list[0]?.length ?? 0) : 0,
mode: reduced ? 'holding' : 'typing',
});
}
const text = list[state.phrase] ?? '';
const many = list.length > 1;
// One step per timeout: type or erase a character, or move between phrases.
useEffect(() => {
const { phrase, count, mode } = state;
if (mode === 'done') return;
const last = phrase === list.length - 1;
let wait = 0;
let next: typeof state;
if (mode === 'typing' && count < text.length) {
// Uneven like a person: quicker inside words, a beat after spaces and punctuation.
const ch = text[count - 1] ?? '';
const pause = /[.,!?;:]/.test(ch) ? 5 : ch === ' ' ? 1.6 : 1;
wait = speed * pause * (0.6 + Math.random() * 0.8);
next = { phrase, count: count + 1, mode };
} else if (mode === 'typing') {
typed.current?.(phrase);
next = { phrase, count, mode: many && (loop || !last) ? 'holding' : 'done' };
} else if (mode === 'holding') {
wait = hold;
const after = (phrase + 1) % list.length;
if (!many || (!loop && last)) next = { phrase, count, mode: 'done' };
// With reduced motion phrases swap whole instead of being erased and retyped.
else if (reduced) next = { phrase: after, count: list[after]?.length ?? 0, mode };
else next = { phrase, count, mode: 'erasing' };
} else if (count > 0) {
wait = eraseSpeed;
next = { phrase, count: count - 1, mode };
} else {
next = { phrase: (phrase + 1) % list.length, count: 0, mode: 'typing' };
}
const id = setTimeout(() => setState(next), wait);
return () => clearTimeout(id);
// `list` is represented by `signature`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, signature, speed, eraseSpeed, hold, loop, reduced]);
// The caret blinks only at rest; while the text moves it stays lit, like a real editor.
const resting = state.mode === 'holding' || state.mode === 'done';
useEffect(() => {
if (!resting || caret === 'none') return;
const id = setInterval(() => setBlinkOn((on) => !on), BLINK);
return () => clearInterval(id);
}, [resting, caret]);
const caretShown = !resting || blinkOn;
const base: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: -0.4,
},
style,
];
return (
<View
accessible
accessibilityRole="text"
// Screen readers get the whole phrase, never a half-typed one.
accessibilityLabel={`${prefix ?? ''}${text}`}
style={containerStyle}
>
<Text
style={base}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
{prefix ? <Text style={prefixStyle}>{prefix}</Text> : null}
{text.slice(0, state.count)}
{caret !== 'none' ? (
<Text
style={{
color: caretColor ?? colors.azure,
opacity: caretShown ? 1 : 0,
fontWeight: caret === 'bar' ? font.weight.regular : undefined,
}}
>
{CARETS[caret]}
</Text>
) : null}
</Text>
</View>
);
}