text input
Number Flip Ledger
A ledger-style number transition that moves only the characters that changed.
iOSAndroidExpo Go
reanimatedworklets
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-workletsInstall tailwind variants
pnpm add tailwind-variantsUsage
NumberFlipLedgerExample.tsximport { NumberFlipLedger } from "@animations/ui/components/text-input/number-flip-ledger/uniwind";const balance = 4280;const change = 240;export function NumberFlipLedgerExample() { return ( <NumberFlipLedger direction={change >= 0 ? "up" : "down"} digitHeight={56} fontSize={44} formatValue={(value) => `${Number(value).toLocaleString("en-US")}`} textClassName="text-emerald-700" value={balance} /> );}number-flip-ledger/config.tsexport const NUMBER_FLIP_LEDGER_CONFIG = { characterWidthRatio: 0.64, defaultDigitHeight: 48, defaultDuration: 420, defaultFontSize: 38, defaultGap: 1,} as const;number-flip-ledger/types.tsexport type NumberFlipDirection = "down" | "up";export type NumberFlipLedgerBaseProps = { accessibilityLabel?: string; characterWidth?: number; digitHeight?: number; direction?: NumberFlipDirection; duration?: number; fontSize?: number; formatValue?: (value: number | string) => string; gap?: number; value: number | string;};number-flip-ledger/uniwind/index.tsximport { View } from "react-native";import Animated from "react-native-reanimated";import type { NumberFlipLedgerBaseProps } from "../types";import { useNumberFlipLedger } from "../use-ledger";import type { SlotsToClasses } from "#shared/types/slots";import { numberFlipLedgerVariants, type NumberFlipLedgerSlot,} from "./variants";export type NumberFlipLedgerProps = NumberFlipLedgerBaseProps & { className?: string; classNames?: SlotsToClasses<NumberFlipLedgerSlot>; textClassName?: string;};export function NumberFlipLedger({ accessibilityLabel, characterWidth, className, classNames, digitHeight, direction, duration, fontSize, formatValue, gap, textClassName, value,}: NumberFlipLedgerProps) { const { characters, digitHeight: resolvedDigitHeight, entering, exiting, fontSize: resolvedFontSize, formattedValue, gap: resolvedGap, resolvedCharacterWidth, } = useNumberFlipLedger({ characterWidth, digitHeight, direction, duration, fontSize, formatValue, gap, value, }); const { character: characterSlot, container, text, } = numberFlipLedgerVariants(); return ( <View accessibilityLabel={accessibilityLabel ?? formattedValue} accessibilityRole="text" accessible className={container({ className: [className, classNames?.container], })} style={{ gap: resolvedGap }} > {characters.map((character, index) => ( <View className={characterSlot({ className: classNames?.character, })} key={index} style={{ height: resolvedDigitHeight, width: resolvedCharacterWidth, }} > <Animated.Text className={text({ className: [ textClassName, classNames?.text, ], })} entering={entering} exiting={exiting} key={character} numberOfLines={1} style={{ fontSize: resolvedFontSize, lineHeight: resolvedDigitHeight, }} > {character} </Animated.Text> </View> ))} </View> );}number-flip-ledger/uniwind/variants.tsimport { tv } from "tailwind-variants";export const numberFlipLedgerSlots = { character: "items-center justify-center overflow-hidden", container: "flex-row items-center", text: "absolute inset-x-0 text-center font-extrabold text-[#171713] [font-variant:tabular-nums]",} as const;export type NumberFlipLedgerSlot = keyof typeof numberFlipLedgerSlots;export const numberFlipLedgerVariants = tv({ slots: numberFlipLedgerSlots,});number-flip-ledger/use-ledger.tsimport { useMemo } from "react";import { Easing, Keyframe, useReducedMotion,} from "react-native-reanimated";import { NUMBER_FLIP_LEDGER_CONFIG } from "./config";import type { NumberFlipLedgerBaseProps } from "./types";export function useNumberFlipLedger({ characterWidth, digitHeight = NUMBER_FLIP_LEDGER_CONFIG.defaultDigitHeight, direction = "up", duration = NUMBER_FLIP_LEDGER_CONFIG.defaultDuration, fontSize = NUMBER_FLIP_LEDGER_CONFIG.defaultFontSize, formatValue, gap = NUMBER_FLIP_LEDGER_CONFIG.defaultGap, value,}: Omit<NumberFlipLedgerBaseProps, "accessibilityLabel">) { if (characterWidth !== undefined && characterWidth <= 0) { throw new Error( "NumberFlipLedger requires characterWidth to be greater than zero.", ); } if (digitHeight <= 0) { throw new Error( "NumberFlipLedger requires digitHeight to be greater than zero.", ); } if (duration <= 0) { throw new Error( "NumberFlipLedger requires duration to be greater than zero.", ); } if (fontSize <= 0) { throw new Error( "NumberFlipLedger requires fontSize to be greater than zero.", ); } if (gap < 0) { throw new Error( "NumberFlipLedger requires gap to be zero or greater.", ); } const reducedMotion = useReducedMotion(); const formattedValue = formatValue?.(value) ?? String(value); const characters = Array.from(formattedValue); const resolvedCharacterWidth = characterWidth ?? fontSize * NUMBER_FLIP_LEDGER_CONFIG.characterWidthRatio; const transitions = useMemo(() => { if (reducedMotion) { return { entering: undefined, exiting: undefined, }; } const directionMultiplier = direction === "up" ? 1 : -1; const travel = digitHeight * 0.72 * directionMultiplier; const entering = new Keyframe({ 0: { opacity: 0, transform: [ { translateY: travel }, { scaleY: 0.82 }, ], }, 72: { opacity: 1, transform: [ { translateY: -travel * 0.06 }, { scaleY: 1.03 }, ], easing: Easing.out(Easing.cubic), }, 100: { opacity: 1, transform: [ { translateY: 0 }, { scaleY: 1 }, ], }, }) .delay(duration * 0.35) .duration(duration * 0.65); const exiting = new Keyframe({ 0: { opacity: 1, transform: [ { translateY: 0 }, { scaleY: 1 }, ], }, 100: { opacity: 0, transform: [ { translateY: -travel }, { scaleY: 0.82 }, ], easing: Easing.in(Easing.cubic), }, }).duration(duration * 0.45); return { entering, exiting, }; }, [digitHeight, direction, duration, reducedMotion]); return { characters, digitHeight, fontSize, formattedValue, gap, resolvedCharacterWidth, ...transitions, };}