Kinetik UI

Slot Text

.md

Text that changes like a slot machine: each character spins through its own kind and brakes onto the new one.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add slot-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/slot-text.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/slot-text.tsx
    View source files

Usage

import { SlotText } from '@/components/kinetik/slot-text';

<SlotText text={`$${total.toLocaleString('en-US')}`} />;

When to use

  • A short figure or word that changes at a moment worth noticing: a reward, a score, a result, a code being revealed.
  • Headlines that cycle through a few words.

When not to use

  • Numbers that change often or continuously, such as a live price. Use Odometer, which rolls without spinning.
  • Long sentences. Every character is its own reel.

Notes

  • Each reel spins through glyphs of the same kind as its new character (digits for digits, letters for letters), so a number never flashes letters.
  • Reels start left to right stagger ms apart and their widths ease to fit the characters they land on; extra reels spin away to nothing.
  • The whole text is read once by screen readers; the reels are hidden.
  • With reduced motion the new text appears at once.

Props

SlotTextProps

PropTypeDefaultDescription
textrequiredstring—The text. Change it and every reel spins to its new character.
textStyleStyleProp<TextStyle>—Style for the characters. Defaults to a large, bold line in the theme text colour.
spinsnumber6Characters each reel passes on the way. Defaults to 6.
staggernumber45Delay between one reel and the next, in ms. Defaults to 45.
accessibilityLabelstring—Read by screen readers. Defaults to the text.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One small text strip per character, moved by one transform; widths are measured once per character. Spin strips are dropped when the reels stop.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 160 MB app memory. Spikes are the first frame of a spin, when every reel lays out its strip of glyphs.

Real-device measurement pending.

Accessibility

Reduced motion
The new text appears at once.
Screen readers
Reads the whole text once; the reels 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/slot-text.tsx
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withTiming,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type SlotTextProps = {
  /** The text. Change it and every reel spins to its new character. */
  text: string;
  /** Style for the characters. Defaults to a large, bold line in the theme text colour. */
  textStyle?: StyleProp<TextStyle>;
  /** Characters each reel passes on the way. Defaults to 6. */
  spins?: number;
  /** Delay between one reel and the next, in ms. Defaults to 45. */
  stagger?: number;
  /** Read by screen readers. Defaults to the text. */
  accessibilityLabel?: string;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const NBSP = String.fromCharCode(0xa0);
const DIGITS = '0123456789';
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
const SPIN = 520;
// Quick off the mark, then a long, soft landing like a reel braking.
const LAND = Easing.bezier(0.18, 0.7, 0.2, 1);

/**
 * The characters a reel passes between `from` and `to`, drawn from the same
 * kind as the target (digits for digits, letters for letters). Seeded, so a
 * render always gives the same reel.
 */
export function reelGlyphs(to: string, count: number, seed: number): string[] {
  const set = /\d/.test(to) ? DIGITS : /[A-Z]/.test(to) ? UPPER : /[a-z]/.test(to) ? LOWER : '';
  if (!set) return [];
  let s = (seed * 2654435761) >>> 0 || 1;
  return Array.from({ length: count }, () => {
    s = (s * 1103515245 + 12345) >>> 0;
    return set[s % set.length]!;
  });
}

/**
 * Text that changes like a slot machine. Each character is a reel that spins
 * through a few glyphs of its own kind and brakes onto the new character,
 * left to right, while the reel's width eases to fit what it lands on.
 */
export function SlotText({
  text,
  textStyle,
  spins = 6,
  stagger = 45,
  accessibilityLabel,
  testID,
  style,
}: SlotTextProps) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const glyph = StyleSheet.flatten([
    {
      color: colors.text,
      fontSize: font.size['3xl'],
      fontWeight: font.weight.bold,
      letterSpacing: font.tracking.tight,
    },
    textStyle,
  ]);
  const size = glyph.fontSize ?? font.size['3xl'];
  const lineHeight = glyph.lineHeight ?? Math.round(size * 1.2);
  const styleKey = JSON.stringify(glyph);

  // Character widths, measured once per character and text style.
  const [metrics, setMetrics] = useState({ styleKey, widths: {} as Record<string, number> });
  if (metrics.styleKey !== styleKey) setMetrics({ styleKey, widths: {} });
  const { widths } = metrics;

  // While text changes length, the longer of the two keeps its reels so the
  // extra ones can spin away to nothing. Once every reel has stopped, the
  // spare reels and the spin strips are dropped.
  const [state, setState] = useState({ text, prev: text, gen: 0, slots: text.length });
  if (state.text !== text) {
    setState({
      text,
      prev: state.text,
      gen: state.gen + 1,
      slots: Math.max(state.text.length, text.length),
    });
  }
  const { gen, slots } = state;
  const settled = state.prev === state.text;
  const settleAfter = SPIN + spins * 18 + slots * stagger + 60;
  useEffect(() => {
    if (settled) return;
    const id = setTimeout(
      () => setState((s) => (s.gen === gen ? { ...s, prev: s.text, slots: s.text.length } : s)),
      reduced ? 0 : settleAfter,
    );
    return () => clearTimeout(id);
  }, [gen, settled, reduced, settleAfter]);

  const chars = Array.from(text);
  const prevChars = Array.from(state.prev);
  const missing = [...new Set(chars)].filter((c) => widths[c] === undefined);

  return (
    <View
      testID={testID}
      accessible
      accessibilityRole="text"
      accessibilityLabel={accessibilityLabel ?? text}
      style={[styles.row, style]}
    >
      {missing.length > 0 ? (
        <View
          style={styles.measure}
          pointerEvents="none"
          importantForAccessibility="no-hide-descendants"
        >
          {missing.map((c) => (
            <Text
              key={c}
              style={glyph}
              onLayout={(e) => {
                const w = e.nativeEvent.layout.width;
                setMetrics((m) =>
                  m.styleKey !== styleKey || m.widths[c] !== undefined
                    ? m
                    : { ...m, widths: { ...m.widths, [c]: w } },
                );
              }}
            >
              {c === ' ' ? NBSP : c}
            </Text>
          ))}
        </View>
      ) : null}
      {Array.from({ length: Math.max(slots, chars.length) }, (_, i) => {
        const to = chars[i] ?? '';
        const from = prevChars[i] ?? '';
        return (
          <Reel
            key={i}
            from={from}
            to={to}
            gen={gen}
            index={i}
            width={to === '' ? 0 : widths[to]}
            spins={spins}
            delay={i * stagger}
            lineHeight={lineHeight}
            glyph={glyph}
            reduced={reduced}
          />
        );
      })}
    </View>
  );
}

type ReelProps = {
  from: string;
  to: string;
  gen: number;
  index: number;
  width: number | undefined;
  spins: number;
  delay: number;
  lineHeight: number;
  glyph: TextStyle;
  reduced: boolean;
};

function Reel({
  from,
  to,
  gen,
  index,
  width,
  spins,
  delay,
  lineHeight,
  glyph,
  reduced,
}: ReelProps) {
  const spinning = gen > 0 && from !== to && !reduced;
  const strip = spinning ? [from, ...reelGlyphs(to || from, spins, gen * 31 + index), to] : [to];
  const travel = (strip.length - 1) * lineHeight;
  const y = useSharedValue(0);
  const w = useSharedValue(width ?? 0);

  useEffect(() => {
    if (!spinning) {
      y.set(0);
      return;
    }
    y.set(0);
    y.set(withDelay(delay, withTiming(-travel, { duration: SPIN + spins * 18, easing: LAND })));
  }, [gen, spinning, travel, delay, spins, y]);
  useEffect(() => {
    if (width === undefined) return;
    w.set(reduced ? width : withDelay(delay, withTiming(width, { duration: SPIN, easing: LAND })));
  }, [width, delay, reduced, w]);

  const box = useAnimatedStyle(() => (width === undefined ? {} : { width: w.get() }));
  const roll = useAnimatedStyle(() => ({ transform: [{ translateY: y.get() }] }));

  return (
    <Animated.View
      style={[styles.reel, { height: lineHeight }, box]}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
    >
      <Animated.View style={roll}>
        {strip.map((c, i) => (
          <Text key={i} style={[glyph, styles.cell, { lineHeight, height: lineHeight }]}>
            {c === ' ' || c === '' ? NBSP : c}
          </Text>
        ))}
      </Animated.View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignSelf: 'flex-start' },
  measure: { position: 'absolute', opacity: 0, flexDirection: 'row', left: 0, top: 0 },
  reel: { overflow: 'hidden', alignItems: 'center' },
  cell: { textAlign: 'center', includeFontPadding: false },
});