Text
Digit Swap
Characters roll independently through fixed-width slots
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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.