Kinetik UI

Typewriter

.md

Text typed out with a human rhythm; several phrases are typed, held, erased and replaced in turn.

v1.0.0Live on web
Component preview

Installation

Kinetik CLI
npx kinetik-ui add typewriter

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/typewriter.json

Install manually

  1. Copy the source files
    • components/kinetik/typewriter.tsx
    View source files

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

PropTypeDefaultDescription
phrasesrequiredstring | readonly string[]—One phrase, or several typed in turn.
prefixstring—Fixed text before the typed part, e.g. "Find your way to ".
speednumber55Average time per character while typing, in ms. Defaults to 55.
eraseSpeednumber26Time per character while erasing, in ms. Defaults to 26.
holdnumber1800How long a finished phrase stays before it is erased, in ms. Defaults to 1800.
loopbooleantrueGo back to the first phrase after the last. Defaults to true.
caret'bar' | 'block' | 'underscore' | 'none''bar'Caret shape. Defaults to `bar`.
caretColorstring—Caret colour. Defaults to the theme azure.
onPhraseTyped(index: number) => void—Called with a phrase's index once it is fully typed.
styleStyleProp<TextStyle>—
prefixStyleStyleProp<TextStyle>—Style of the prefix, on top of `style`.
containerStyleStyleProp<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>
  );
}