{"slug":"number","name":"Number Animation","description":"Rolling tickers and count-ups.","group":"Motion","detail_url":"https://beui.dev/native/r/number","page_url":"https://beui.dev/react-native/number","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/number?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/number","libraries":["react","react-native","react-native-reanimated"],"install":["npx expo install react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/number/animated-number.tsx","content":"import { useEffect, useRef, useState } from \"react\";\nimport { Text, type TextProps } from \"react-native\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport { glyphStyles } from \"./use-glyph-cell\";\n\nconst ease = EASE_OUT.factory();\n\nexport type AnimatedNumberProps = Omit<TextProps, \"children\"> & {\n  value: number;\n  /** Count duration in milliseconds. Defaults to 1200. */\n  duration?: number;\n  /** Formats each frame. Defaults to a rounded, locale-grouped integer. */\n  format?: (value: number) => string;\n  /** Disables counting independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n};\n\nconst defaultFormat = (value: number) => Math.round(value).toLocaleString();\n\n/** Counts from the previous value (0 at first) to `value` on beUI's ease-out curve. */\nexport function AnimatedNumber({\n  value,\n  duration = 1200,\n  format = defaultFormat,\n  disableAnimation = false,\n  className = \"\",\n  style,\n  accessibilityLabel,\n  ...props\n}: AnimatedNumberProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const [display, setDisplay] = useState(animate ? 0 : value);\n  const current = useRef(animate ? 0 : value);\n\n  useEffect(() => {\n    if (!animate) {\n      current.current = value;\n      setDisplay(value);\n      return;\n    }\n    // Continue from wherever an interrupted count stopped.\n    const start = current.current;\n    const startedAt = Date.now();\n    let frame = 0;\n    const tick = () => {\n      const t = Math.min(1, (Date.now() - startedAt) / duration);\n      const next = start + (value - start) * ease(t);\n      current.current = next;\n      setDisplay(next);\n      if (t < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [animate, duration, value]);\n\n  return (\n    <Text\n      {...props}\n      // Screen readers get the final value rather than every frame.\n      accessibilityLabel={accessibilityLabel ?? format(value)}\n      className={className}\n      style={[glyphStyles.tabular, style]}\n    >\n      {format(display)}\n    </Text>\n  );\n}\n","type":"component"},{"path":"src/components/number/index.ts","content":"export type { AnimatedNumberProps } from \"./animated-number\";\nexport { AnimatedNumber } from \"./animated-number\";\nexport type { NumberTickerProps } from \"./number-ticker\";\nexport { NumberTicker } from \"./number-ticker\";\n","type":"component"},{"path":"src/components/number/number-ticker.tsx","content":"import { useEffect, useMemo, useState } from \"react\";\nimport { StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport { type GlyphCell, glyphStyles, useGlyphCell } from \"./use-glyph-cell\";\n\nconst DIGITS = Array.from({ length: 10 }, (_, digit) => digit);\n\nexport type NumberTickerProps = Omit<ViewProps, \"children\"> & {\n  value: number;\n  /** Pads with leading zeros to this many digits. */\n  pad?: number;\n  /** Per-digit roll duration in milliseconds. Defaults to 900. */\n  duration?: number;\n  /** Delay between digits on the first reveal, in milliseconds. Defaults to 40. */\n  stagger?: number;\n  prefix?: string;\n  suffix?: string;\n  /** Inserts locale group separators. */\n  locale?: boolean;\n  /** Custom formatter for the rounded value. */\n  format?: (value: number) => string;\n  /** Disables rolling independently of the system preference. */\n  disableAnimation?: boolean;\n  /** Text style for every glyph, such as `text-3xl font-semibold`. */\n  className?: string;\n  /** Extra classes for digit columns only. */\n  digitClassName?: string;\n};\n\nfunction DigitColumn({\n  digit,\n  delay,\n  duration,\n  cell,\n  className,\n  reduce,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  cell: GlyphCell;\n  className: string;\n  reduce: boolean;\n}) {\n  const offset = useSharedValue(0);\n\n  useEffect(() => {\n    const target = -digit * cell.height;\n    offset.value = reduce\n      ? target\n      : withDelay(delay, withTiming(target, { duration, easing: EASE_OUT }));\n  }, [cell.height, delay, digit, duration, offset, reduce]);\n\n  const columnStyle = useAnimatedStyle(() => ({\n    transform: [{ translateY: offset.value }],\n  }));\n\n  return (\n    <View style={[styles.window, { width: cell.width, height: cell.height }]}>\n      <Animated.View style={[styles.column, columnStyle]}>\n        {DIGITS.map((n) => (\n          <Text\n            key={n}\n            className={className}\n            style={[glyphStyles.tabular, { height: cell.height }]}\n          >\n            {n}\n          </Text>\n        ))}\n      </Animated.View>\n    </View>\n  );\n}\n\n/** Each digit rolls through a 0–9 column to its new value. */\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 900,\n  stagger = 40,\n  prefix,\n  suffix,\n  locale = false,\n  format,\n  disableAnimation = false,\n  className = \"\",\n  digitClassName = \"\",\n  accessibilityLabel,\n  ...props\n}: NumberTickerProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const { cell, measurer } = useGlyphCell(className);\n  const [entered, setEntered] = useState(false);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : String(rounded);\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [format, locale, pad, value]);\n\n  // Key glyphs by place value from the right, so a changing digit keeps its\n  // column and rolls instead of remounting, and growth adds columns on the left.\n  const glyphs = Array.from(text, (char, index) => ({\n    char,\n    id: `place-${text.length - 1 - index}`,\n  }));\n\n  // The stagger is an entrance flourish; later updates roll every digit at once.\n  useEffect(() => {\n    if (!cell || entered) return;\n    const timer = setTimeout(\n      () => setEntered(true),\n      duration + glyphs.length * stagger,\n    );\n    return () => clearTimeout(timer);\n  }, [cell, duration, entered, glyphs.length, stagger]);\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityRole=\"text\"\n      accessibilityLabel={\n        accessibilityLabel ?? `${prefix ?? \"\"}${text}${suffix ?? \"\"}`\n      }\n      style={[styles.row, props.style]}\n    >\n      {measurer}\n      {prefix ? <Text className={className}>{prefix}</Text> : null}\n      {cell ? (\n        glyphs.map(({ char, id }, index) =>\n          /\\d/.test(char) ? (\n            <DigitColumn\n              key={id}\n              digit={Number(char)}\n              delay={entered ? 0 : index * stagger}\n              duration={duration}\n              cell={cell}\n              className={`${className} ${digitClassName}`}\n              reduce={reduce}\n            />\n          ) : (\n            <Text key={id} className={className}>\n              {char}\n            </Text>\n          ),\n        )\n      ) : (\n        // Hold the space with plain text until the digit cell is measured.\n        <Text className={className} style={styles.hidden}>\n          {text}\n        </Text>\n      )}\n      {suffix ? <Text className={className}>{suffix}</Text> : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: \"row\", alignItems: \"center\", alignSelf: \"flex-start\" },\n  window: { overflow: \"hidden\" },\n  column: { position: \"absolute\", left: 0, right: 0, top: 0 },\n  hidden: { opacity: 0 },\n});\n","type":"component"},{"path":"src/components/number/use-glyph-cell.tsx","content":"import { useState } from \"react\";\nimport { StyleSheet, Text, View } from \"react-native\";\n\nexport type GlyphCell = { width: number; height: number };\n\n/**\n * Measures one tabular \"0\" in the caller's text style, giving the fixed cell\n * that rolling glyphs travel through. Render `measurer` once inside the\n * component; `cell` stays null until the first layout.\n */\nexport function useGlyphCell(className: string) {\n  const [cell, setCell] = useState<GlyphCell | null>(null);\n\n  const measurer = (\n    <View style={glyphStyles.probe}>\n      <Text\n        className={className}\n        style={glyphStyles.tabular}\n        onLayout={({ nativeEvent: { layout } }) => {\n          setCell((current) =>\n            current?.width === layout.width && current.height === layout.height\n              ? current\n              : { width: layout.width, height: layout.height },\n          );\n        }}\n      >\n        0\n      </Text>\n    </View>\n  );\n\n  return { cell, measurer };\n}\n\nexport const glyphStyles = StyleSheet.create({\n  tabular: { fontVariant: [\"tabular-nums\"] },\n  // Wide and invisible so the probe always lays out at its natural size.\n  probe: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: 10000,\n    flexDirection: \"row\",\n    alignItems: \"flex-start\",\n    opacity: 0,\n    pointerEvents: \"none\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}