Formatting values

Turn numbers into the text you want to show: decimals, currency, locales, compact notation and units.

value is displayed exactly as it's given. A number goes through String(), and a string is shown character for character. Formatting is up to you, and anything you format animates: symbols, separators, signs and units are glyphs like any other.

Numbers as strings

String() drops trailing zeros, so 1.10 shows as 1.1, and a price that moves from 1.10 to 1.15 changes length. Fix the number of decimals with toFixed when they should always be there:

<NumericText value={price.toFixed(2)} monospacedDigits />

Currency

Intl.NumberFormat does the grouping, the symbol and its placement:

const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

<NumericText value={usd.format(balance)} fontSize={48} fontWeight="700" monospacedDigits />

Usage

Balance.tsx
import { NumericText } from 'expo-content-transition';
import { useState } from 'react';
import { Pressable, View } from 'react-native';

const CURRENCIES = [
  { code: 'USD', locale: 'en-US', rate: 1 },
  { code: 'EUR', locale: 'de-DE', rate: 0.92 },
  { code: 'GBP', locale: 'en-GB', rate: 0.79 },
  { code: 'JPY', locale: 'ja-JP', rate: 149 },
];

const format = (usd: number, { code, locale, rate }: (typeof CURRENCIES)[number]) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(usd * rate);

export function Balance() {
  const [balance, setBalance] = useState(2480.5);
  const [index, setIndex] = useState(0);
  const currency = CURRENCIES[index];

  return (
    <View style={{ alignItems: 'center', gap: 16 }}>
      <Pressable onPress={() => setIndex((i) => (i + 1) % CURRENCIES.length)}>
        <NumericText value={currency.code} fontSize={12} color="#6E6E73" direction="up" />
      </Pressable>
      <NumericText
        value={format(balance, currency)}
        decimalSeparator={currency.locale === 'de-DE' ? ',' : '.'}
        fontSize={56}
        fontWeight="700"
        monospacedDigits
      />
      {/* Deposit and withdraw buttons call setBalance */}
    </View>
  );
}

Locales

Many locales use a comma for the decimal point and a dot or space for grouping. Tell NumericText which character is the decimal separator, or it will line digits up around the wrong one. Reading it from the formatter keeps the two in agreement:

function decimalSeparator(locale: string) {
  return new Intl.NumberFormat(locale).formatToParts(1.1).find((part) => part.type === 'decimal')?.value ?? '.';
}

const format = new Intl.NumberFormat(locale, { style: 'currency', currency });

<NumericText value={format.format(total)} decimalSeparator={decimalSeparator(locale)} />

Compact numbers

Compact notation turns 1,480 into 1.5K. When a value crosses a boundary, like 999 to 1K, the whole string changes shape and every glyph transitions:

Usage

LikeButton.tsx
import { NumericText } from 'expo-content-transition';
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { Heart } from './icons';

const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });

export function LikeButton() {
  const [count, setCount] = useState(996);
  const [liked, setLiked] = useState(false);

  const toggle = () => {
    setLiked((value) => !value);
    setCount((value) => value + (liked ? -1 : 1));
  };

  return (
    <Pressable onPress={toggle} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
      <Heart filled={liked} />
      <NumericText value={compact.format(count)} fontSize={20} fontWeight="600" monospacedDigits />
    </Pressable>
  );
}

Percentages and units

Suffixes such as %, km or bpm stay put while the digits in front of them change. For values that update many times a second, a shorter duration keeps the motion from lagging behind:

Downloading…

Usage

Progress.tsx
import { NumericText } from 'expo-content-transition';
import { View } from 'react-native';

export function Progress({ percent }: { percent: number }) {
  return (
    <View style={{ gap: 12 }}>
      <NumericText
        value={`${Math.round(percent)}%`}
        duration={260}
        fontSize={44}
        fontWeight="600"
        monospacedDigits
      />
      <View style={{ height: 6, borderRadius: 3, backgroundColor: '#E5E5EA' }}>
        <View style={{ width: `${percent}%`, height: 6, borderRadius: 3, backgroundColor: '#111' }} />
      </View>
    </View>
  );
}

Signs

Put the sign in the string. A change from +1.2% to -0.4% transitions the sign like any other glyph. Using the typographic minus (−, U+2212) keeps it the same width as + in most fonts, and NumericText reads it as a negative number when working out the direction.

const signed = `${change >= 0 ? '+' : '−'}${Math.abs(change).toFixed(2)}%`;

Times

Pad times so their length doesn't change, and force the direction, since a clock's digits don't always grow as time goes forward:

Usage

Clock.tsx
import { NumericText } from 'expo-content-transition';
import { useEffect, useState } from 'react';

const now = () =>
  new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });

export function Clock() {
  const [time, setTime] = useState(now);

  useEffect(() => {
    const timer = setInterval(() => setTime(now()), 1000);
    return () => clearInterval(timer);
  }, []);

  return <NumericText value={time} direction="up" fontSize={56} fontWeight="500" monospacedDigits />;
}

Words

Any string works. Text with no digits is matched from its first letter, so a label changes in place:

<NumericText value={isSaving ? 'Saving' : 'Saved'} direction="up" />