Veltrix

text input

Number Flip Ledger

A ledger-style number transition that moves only the characters that changed.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedworklets
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-worklets

Install tailwind variants

pnpm add tailwind-variants

Usage

NumberFlipLedgerExample.tsx
import { 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}    />  );}

Component files

5 files

number-flip-ledger/config.ts
export const NUMBER_FLIP_LEDGER_CONFIG = {  characterWidthRatio: 0.64,  defaultDigitHeight: 48,  defaultDuration: 420,  defaultFontSize: 38,  defaultGap: 1,} as const;
number-flip-ledger/types.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

NumberFlipLedger