Skip to content
pixelfor Expo
Ctrl K

Text

Digit Swap

Characters roll independently through fixed-width slots

Try it on your phone

Digit Swap

Scan to open Digit Swap in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

digit-swap.tsx

import React, { useEffect, useRef, useState } from "react"
import { Text, View, type TextStyle } from "react-native"
import Animated, {
  FadeIn,
  FadeOut,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withDelay,
  withTiming,
  Easing,
} from "react-native-reanimated"
export interface DigitSwapProps {
  value: string | number
  animationKey?: string | number
  direction?: "up" | "down"
  duration?: number
  stagger?: number
  suffixLength?: number
  color?: string
  suffixColor?: string
  letterSpacing?: number
  fontSize?: number
  fontFamily?: string
  testID?: string
}
function Glyph({
  glyph,
  previous,
  index,
  direction,
  duration,
  stagger,
  height,
  width,
  color,
  fontFamily,
  letterSpacing,
  replay,
}: {
  glyph: string
  previous: string
  index: number
  direction: "up" | "down"
  duration: number
  stagger: number
  height: number
  width: number
  color: string
  fontFamily?: string
  letterSpacing: number
  replay: boolean
}) {
  const reduced = useReducedMotion(),
    progress = useSharedValue(1)
  useEffect(() => {
    if (glyph === previous && !replay) return
    progress.value = 0
    progress.value = reduced
      ? 1
      : withDelay(
          index * stagger * 1000,
          withTiming(1, {
            duration: duration * 1000,
            easing: Easing.bezier(0.16, 1, 0.3, 1),
          })
        )
  }, [glyph, previous, replay, index, stagger, duration, reduced, progress])
  const sign = direction === "up" ? 1 : -1
  const entering = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ translateY: sign * height * 0.45 * (1 - progress.value) }],
  }))
  const exiting = useAnimatedStyle(() => ({
    opacity: 1 - progress.value,
    transform: [{ translateY: -sign * height * 0.45 * progress.value }],
  }))
  const text = {
    position: "absolute" as const,
    width,
    textAlign: "center" as const,
    lineHeight: height,
    fontSize: height / 1.1,
    color,
    fontFamily,
    letterSpacing,
    fontVariant: ["tabular-nums"] as TextStyle["fontVariant"],
  }
  return (
    <View style={{ width, height, overflow: "hidden" }}>
      <Animated.Text style={[text, exiting]}>{previous}</Animated.Text>
      <Animated.Text style={[text, entering]}>{glyph}</Animated.Text>
    </View>
  )
}
export function DigitSwap({
  value,
  animationKey,
  direction = "up",
  duration = 0.18,
  stagger = 0.006,
  suffixLength = 0,
  color = "#fff",
  suffixColor = color,
  letterSpacing = 0,
  fontSize = 16,
  fontFamily = "monospace",
  testID,
}: DigitSwapProps) {
  const current = String(value),
    previous = useRef(current),
    previousKey = useRef(animationKey)
  const [glyphWidth, setGlyphWidth] = useState(fontSize * 0.6)
  const old = previous.current
  const replay = previousKey.current !== animationKey
  useEffect(() => {
    previous.current = current
    previousKey.current = animationKey
  }, [current, animationKey])
  return (
    <View
      testID={testID}
      accessible
      accessibilityLabel={current}
      style={{ flexDirection: "row", alignItems: "center" }}
    >
      <Text aria-hidden accessibilityElementsHidden importantForAccessibility="no-hide-descendants" onLayout={({ nativeEvent }) => setGlyphWidth(nativeEvent.layout.width)} style={{ position: "absolute", opacity: 0, fontFamily, fontSize }}>0</Text>
      {[...current].map((glyph, i) => glyph === " " ? <View key={i} style={{ width: glyphWidth * 0.7 }} /> : (
          <Glyph
            key={`${i}-${animationKey ?? "stable"}`}
            glyph={glyph}
            previous={old[i] ?? ""}
            index={i}
            direction={direction}
            duration={duration}
            stagger={stagger}
            height={fontSize * 1.1}
            width={glyphWidth}
            color={i >= current.length - suffixLength ? suffixColor : color}
            fontFamily={fontFamily}
            letterSpacing={letterSpacing}
            replay={replay}
          />
        )
      )}
    </View>
  )
}

Give the gestures and motion a run on a real Android phone.

Back to Be UI