/** * TextAnimation — the five ways a piece of text arrives. * * ```tsx * * * * * * ``` * * ## Why one component with five parts * * They are one idea — a value that changes and wants to be seen changing — * and they share every prop that says *how*: `duration`, `delay`, `loop`, * `enabled`. Put on the `TextAnimation` root, those become the defaults for * everything inside it, so a hero with three of these in it is configured once * rather than three times. * * ## What React Native forces * * Nested `Text` is the only thing that gets real line-breaking, and it cannot * be transformed — a `translateY` on it is ignored. So the two parts that * animate a whole string, `Typing` and `Counting`, stay real text and keep * wrapping; the three that slide glyphs past each other lay out as rows of * views, which buys the transform and costs line-breaking. None of the three * is ever a paragraph, so the trade only ever falls the right way. * * ## Why the digits are drawn rather than measured * * A number animated as React state is a re-render per frame. `Counting` runs * the value on the UI thread and only crosses back when the *rounded* number * changes, which for a whole number is a couple of dozen times rather than * sixty a second. The sliding and scrolling parts do not cross back at all: * every digit is already rendered, and the animation is a transform on a * column of them. */ import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Text as RNText, View, type ViewProps } from 'react-native'; import Animated, { Easing, cancelAnimation, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withSpring, withTiming, } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { cn } from '../../utils/cn'; import { Text, type TextProps } from '../../primitives/text'; const AnimatedText = Animated.createAnimatedComponent(RNText); /** How long a caret spends lit, and then dark. */ const CARET_PERIOD = 520; /** The spring every number rides. Slow enough to read the digits going past. */ const NUMBER_SPRING = { damping: 26, stiffness: 90, mass: 1 } as const; /** How far a rotating phrase travels, in points. */ const ROTATE_DISTANCE = 22; const textAnimationVariants = tv({ slots: { /* * `tabular-nums` on everything numeric, without exception. Proportional * digits are different widths, so a rolling number changes width as it * rolls and shoves whatever is beside it back and forth — which reads as * the layout being broken rather than as the number being alive. */ digits: 'flex-row items-center tabular-nums', column: 'items-center', caret: 'text-foreground', highlight: 'rounded-lg bg-secondary', }, }); /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ interface TextAnimationContextValue { duration?: number; delay?: number; loop?: boolean; enabled?: boolean; } const TextAnimationContext = createContext({}); export interface TextAnimationProps extends ViewProps { className?: string; /** * How long one pass takes, in milliseconds. What that measures depends on * the part: a keystroke for `Typing`, a phrase's turn on screen for * `Rotating`, the whole journey for the three that count. */ duration?: number; /** How long to wait before starting, in milliseconds. */ delay?: number; /** Start again from the beginning when the run finishes. */ loop?: boolean; /** * Animate at all. `false` draws the finished text or the final number * immediately, which is also what a reduced-motion setting does. */ enabled?: boolean; children?: ReactNode; } /** * Shared configuration, and a row to lay the parts out in. * * Optional in every case — each part works standalone with its own props, and * this is only here so a line with three of them in it says `duration` once. */ function TextAnimationRoot({ className, duration, delay, loop, enabled, children, ...props }: TextAnimationProps) { const value = useMemo( () => ({ duration, delay, loop, enabled }), [delay, duration, enabled, loop] ); return ( {children} ); } TextAnimationRoot.displayName = 'TextAnimation'; /** * The part's own prop, then the root's, then the part's own default. * * Written out because `??` alone would take the root's value over an explicit * `false` on the part, and "the root says loop, this one does not" is the * whole reason the root's values are defaults rather than settings. */ function useShared( key: K, own: TextAnimationContextValue[K], fallback: NonNullable ): NonNullable { const inherited = useContext(TextAnimationContext); return (own ?? inherited[key] ?? fallback) as NonNullable; } /** * The same colour at a given alpha. * * A gradient stop of `transparent` is black at zero alpha on Android, so a * fade to it arrives through a grey smear on the way — it has to be the same * colour, faded. */ function withAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { const hex = color.slice(1); const full = hex.length === 3 ? hex .split('') .map((c) => c + c) .join('') : hex.slice(0, 6); const r = parseInt(full.slice(0, 2), 16); const g = parseInt(full.slice(2, 4), 16); const b = parseInt(full.slice(4, 6), 16); if (Number.isNaN(r + g + b)) return color; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } const channels = color.match(/rgba?\(([^)]+)\)/)?.[1]; if (channels) { const [r, g, b] = channels.split(',').map((part) => part.trim()); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return color; } /** True when nothing should move: asked for, or asked for by the system. */ function useStill(enabled: boolean): boolean { return useReducedMotion() || !enabled; } /* -------------------------------------------------------------------------- */ /* Typing */ /* -------------------------------------------------------------------------- */ export interface TextAnimationTypingProps extends Omit { className?: string; /** * What to type. An array is typed, held, erased and replaced by the next, * which is the shape a rotating headline wants. */ text: string | string[]; /** Milliseconds per keystroke. */ duration?: number; /** Milliseconds before the first keystroke. */ delay?: number; /** How long a finished string sits before it is erased, in milliseconds. */ hold?: number; /** Start again after the last string. Only means anything for an array. */ loop?: boolean; /** Draw a blinking caret after the text. */ caret?: boolean; /** Styles the caret. */ caretClassName?: string; /** Called once the last string has finished being typed. */ onDone?: () => void; enabled?: boolean; } /** * A string arriving one character at a time. * * The text is React state rather than anything on the UI thread, and it has * to be: a character is a different string, and a string is a re-render * whichever thread decided on it. That is fine at a keystroke every 50-odd * milliseconds, which is three orders of magnitude slower than a frame. * * It reserves no space. A line that grows as it types pushes whatever is under * it down the screen on every keystroke, so give the container a height, or * type into a block that has one already. */ function TextAnimationTyping({ className, text, duration, delay, hold = 1400, loop, caret = false, caretClassName, onDone, enabled, ...props }: TextAnimationTypingProps) { const step = useShared('duration', duration, 55); const wait = useShared('delay', delay, 0); const repeat = useShared('loop', loop, false); const still = useStill(useShared('enabled', enabled, true)); const strings = useMemo(() => (typeof text === 'string' ? [text] : text), [text]); const [shown, setShown] = useState(() => (still ? (strings[0] ?? '') : '')); const [typing, setTyping] = useState(false); // Held in a ref so the effect below does not restart the whole run every // time the caller passes a new inline arrow function. const done = useRef(onDone); done.current = onDone; useEffect(() => { if (still) { setShown(strings[0] ?? ''); setTyping(false); return; } const timers: ReturnType[] = []; let cancelled = false; const run = (index: number) => { const value = strings[index] ?? ''; let at = 0; setTyping(true); const type = () => { if (cancelled) return; setShown(value.slice(0, at)); if (at < value.length) { at += 1; timers.push(setTimeout(type, step)); return; } setTyping(false); const last = index === strings.length - 1; if (last && !repeat) { done.current?.(); return; } timers.push(setTimeout(() => erase(value, index), hold)); }; type(); }; const erase = (value: string, index: number) => { if (cancelled) return; let at = value.length; setTyping(true); const back = () => { if (cancelled) return; setShown(value.slice(0, at)); if (at > 0) { at -= 1; timers.push(setTimeout(back, step)); return; } setTyping(false); run(index === strings.length - 1 ? 0 : index + 1); }; back(); }; timers.push(setTimeout(() => run(0), wait)); return () => { cancelled = true; timers.forEach(clearTimeout); }; }, [hold, repeat, step, still, strings, wait]); return ( /* * The caret is nested *inside* the text rather than laid out beside it. As * a sibling in a row it sits against the right of the whole block, so the * moment a line wraps it stops being after the last character and starts * floating at the end of the paragraph — which is exactly where a caret * should never be. Nested, it is one more glyph in the flow and lands * wherever typing has got to, on whichever line that is. */ {shown} {caret ? : null} ); } TextAnimationTyping.displayName = 'TextAnimation.Typing'; /** * The caret. Solid while characters are arriving, blinking while they are not * — which is what a real one does, and what says "waiting" rather than * "finished". * * A glyph rather than a view, because only a glyph takes part in line-breaking * — and because a character is sized by the font it is in, so it is the right * height at every text size without being told any of them. Nested text can be * faded but not transformed, which the opacity here is well inside. */ function TypingCaret({ blinking, still, className, }: { blinking: boolean; still: boolean; className?: string; }) { const { caret } = textAnimationVariants(); const opacity = useSharedValue(1); useEffect(() => { if (still || !blinking) { cancelAnimation(opacity); opacity.value = 1; return; } opacity.value = withRepeat( withSequence( withTiming(1, { duration: CARET_PERIOD }), withTiming(0, { duration: CARET_PERIOD }) ), -1, false ); return () => cancelAnimation(opacity); }, [blinking, opacity, still]); const style = useAnimatedStyle(() => ({ opacity: opacity.value })); return ( {'\u258F'} ); } /* -------------------------------------------------------------------------- */ /* Rotating */ /* -------------------------------------------------------------------------- */ export interface TextAnimationRotatingProps extends Omit { className?: string; /** The phrases to cycle. One string never rotates, which is a valid state. */ text: string | string[]; /** How long each phrase holds, in milliseconds. */ duration?: number; /** Milliseconds before the first change. */ delay?: number; enabled?: boolean; } /** * One phrase replaced by the next, the outgoing one leaving upward and the * incoming one arriving from below. * * The box is sized by the longest phrase and not by the current one. A box * that resizes as the words change makes the line around it jump, which is * more distracting than the effect is interesting — and a box sized by the * *first* phrase clips every longer one that follows, which is worse than * either. * * So the width comes from a sizer: every phrase, laid out invisibly in a view * with no height. Yoga still takes a column's width from its children when * their height is fixed at zero, so the box ends up as wide as the longest * phrase and as tall as one line of them. */ function TextAnimationRotating({ className, text, duration, delay, enabled, ...props }: TextAnimationRotatingProps) { const period = useShared('duration', duration, 2200); const wait = useShared('delay', delay, 0); const still = useStill(useShared('enabled', enabled, true)); const phrases = useMemo(() => (typeof text === 'string' ? [text] : text), [text]); const [index, setIndex] = useState(0); useEffect(() => { if (still || phrases.length < 2) return; let interval: ReturnType | undefined; const start = setTimeout(() => { setIndex((current) => (current + 1) % phrases.length); interval = setInterval( () => setIndex((current) => (current + 1) % phrases.length), period ); }, wait + period); return () => { clearTimeout(start); if (interval) clearInterval(interval); }; }, [period, phrases.length, still, wait]); return ( {/* The sizer. No height, so it contributes nothing but width, and invisible, so what it contributes is only a measurement. */} {phrases.map((phrase) => ( {phrase} ))} {phrases.map((phrase, at) => ( ))} ); } TextAnimationRotating.displayName = 'TextAnimation.Rotating'; function RotatingPhrase({ phrase, measured, active, still, textProps, }: { phrase: string; measured: boolean; active: boolean; still: boolean; textProps: Omit; }) { const travel = useSharedValue(active ? 0 : ROTATE_DISTANCE); const opacity = useSharedValue(active ? 1 : 0); useEffect(() => { if (still) { travel.value = active ? 0 : ROTATE_DISTANCE; opacity.value = active ? 1 : 0; return; } if (active) { // From below, not from wherever it was left: a phrase coming back after // a full cycle should arrive the same way it did the first time. travel.value = ROTATE_DISTANCE; travel.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.cubic) }); opacity.value = withTiming(1, { duration: 240 }); } else { travel.value = withTiming(-ROTATE_DISTANCE, { duration: 300, easing: Easing.in(Easing.cubic), }); opacity.value = withTiming(0, { duration: 200 }); } }, [active, opacity, still, travel]); const style = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ translateY: travel.value }], })); return ( {phrase} ); } /* -------------------------------------------------------------------------- */ /* Counting */ /* -------------------------------------------------------------------------- */ /** Formats a number the way both counting parts agree to. */ function useFormatter(decimals: number, formatOptions?: Intl.NumberFormatOptions) { return useMemo(() => { if (formatOptions) { try { const format = new Intl.NumberFormat(undefined, formatOptions); return (value: number) => format.format(value); } catch { // Some engines ship a partial Intl; fall through to the plain form. } } return (value: number) => value.toFixed(decimals); }, [decimals, formatOptions]); } export interface TextAnimationCountingProps extends Omit { className?: string; /** Where the number ends up. */ value: number; /** Where it starts from. Defaults to zero. */ from?: number; /** How long the whole journey takes, in milliseconds. */ duration?: number; /** Milliseconds before it starts. */ delay?: number; /** Digits after the point. */ decimals?: number; /** * Formatting for the number, as `Intl.NumberFormat` options — a currency, a * percentage, grouped thousands. Falls back to a plain fixed-point string on * an engine whose `Intl` cannot do it. */ formatOptions?: Intl.NumberFormatOptions; enabled?: boolean; } /** * A number counting up to itself. * * The text is rewritten from a Reanimated reaction rather than from state: * sixty re-renders a second for a number that is only ever a string is the * kind of thing that makes an otherwise still screen drop frames. Only the * rounded value crosses back to JavaScript, so the work per frame is one * comparison and, at most, one `setState` on a value that actually changed. */ function TextAnimationCounting({ className, value, from = 0, duration, delay, decimals = 0, formatOptions, enabled, ...props }: TextAnimationCountingProps) { const span = useShared('duration', duration, 1200); const wait = useShared('delay', delay, 0); const still = useStill(useShared('enabled', enabled, true)); const format = useFormatter(decimals, formatOptions); const progress = useSharedValue(still ? value : from); const [shown, setShown] = useState(() => (still ? value : from)); useEffect(() => { if (still) { cancelAnimation(progress); progress.value = value; setShown(value); return; } progress.value = from; const timer = setTimeout(() => { progress.value = withTiming(value, { duration: span, // Fast at first and settling at the end, which is what makes the last // few digits readable instead of a blur that stops. easing: Easing.out(Easing.cubic), }); }, wait); return () => { clearTimeout(timer); cancelAnimation(progress); }; }, [from, progress, span, still, value, wait]); const factor = 10 ** decimals; useAnimatedReaction( () => Math.round(progress.value * factor) / factor, (current, previous) => { if (current !== previous) runOnJS(setShown)(current); }, [factor] ); return ( {format(shown)} ); } TextAnimationCounting.displayName = 'TextAnimation.Counting'; /* -------------------------------------------------------------------------- */ /* Sliding */ /* -------------------------------------------------------------------------- */ export interface TextAnimationSlidingProps extends ViewProps { className?: string; /** The number to show. Each digit rolls to its new value independently. */ value: number; /** Digits after the point. */ decimals?: number; /** Pad the whole part to this many digits with leading zeroes. */ padStart?: number; /** A separator every three digits — `','` for `1,024`. */ thousandSeparator?: string; /** The decimal mark. */ decimalSeparator?: string; /** Styles the digits. */ textClassName?: string; /** Size of the digits, as on `Text`. */ size?: TextProps['size']; /** Weight of the digits, as on `Text`. */ weight?: TextProps['weight']; /** Milliseconds before the roll starts. */ delay?: number; enabled?: boolean; } /** * An odometer: every digit is a column of ten, and each column rolls to the * one it should be showing. * * Nothing here is measured at runtime. A column is ten stacked digits and the * whole thing is moved by a fraction of its own height, so the roll needs no * `onLayout`, no first frame at the wrong offset, and no re-render per digit * per frame. The height comes from one hidden `0` in flow, which is what makes * the column as tall as the font is rather than as tall as a number happens to * be. */ function TextAnimationSliding({ className, value, decimals = 0, padStart = 1, thousandSeparator, decimalSeparator = '.', textClassName, size, weight, delay, enabled, ...props }: TextAnimationSlidingProps) { const wait = useShared('delay', delay, 0); const still = useStill(useShared('enabled', enabled, true)); const { digits } = textAnimationVariants(); const { whole, fraction, negative } = useMemo(() => { const absolute = Math.abs(value); const fixed = absolute.toFixed(decimals); const [left = '0', right = ''] = fixed.split('.'); return { whole: left.padStart(padStart, '0'), fraction: right, negative: value < 0, }; }, [decimals, padStart, value]); const columns: ReactNode[] = []; whole.split('').forEach((digit, index) => { const fromEnd = whole.length - index - 1; columns.push( ); if (thousandSeparator && fromEnd > 0 && fromEnd % 3 === 0) { columns.push( {thousandSeparator} ); } }); if (fraction) { columns.push( {decimalSeparator} ); fraction.split('').forEach((digit, index) => { columns.push( ); }); } return ( {negative ? ( − ) : null} {columns} ); } TextAnimationSliding.displayName = 'TextAnimation.Sliding'; const TEN = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; function SlidingColumn({ digit, delay, still, className, size, weight, }: { digit: number; delay: number; still: boolean; className?: string; size?: TextProps['size']; weight?: TextProps['weight']; }) { const { column } = textAnimationVariants(); const offset = useSharedValue(still ? digit : 0); useEffect(() => { if (still) { cancelAnimation(offset); offset.value = digit; return; } const timer = setTimeout(() => { offset.value = withSpring(digit, NUMBER_SPRING); }, delay); return () => { clearTimeout(timer); cancelAnimation(offset); }; }, [delay, digit, offset, still]); /* * A percentage of the column's own height, so nothing has to be measured. * The column is ten digits tall; moving it by `-n/10` of that puts the nth * one in the window, whatever the font size turns out to be. */ const style = useAnimatedStyle(() => ({ transform: [{ translateY: `${(-offset.value / TEN.length) * 100}%` }], })); return ( {/* In flow and invisible: it is what gives the window a digit's height and a digit's width without either being a number in the source. */} 0 {TEN.map((n) => ( {n} ))} ); } /* -------------------------------------------------------------------------- */ /* Scrolling */ /* -------------------------------------------------------------------------- */ export interface TextAnimationScrollingProps extends ViewProps { className?: string; /** The value to land on. */ value: number; /** The gap between the values either side of it. */ step?: number; /** How many values to show above and below the one in the window. */ around?: number; /** How long the run takes, in milliseconds. */ duration?: number; /** Milliseconds before it starts. */ delay?: number; /** Formatting for each value, as `Intl.NumberFormat` options. */ formatOptions?: Intl.NumberFormatOptions; /** Styles the values. */ textClassName?: string; /** Size of the values, as on `Text`. */ size?: TextProps['size']; /** Weight of the values, as on `Text`. */ weight?: TextProps['weight']; /** * Draw a band behind the value in the window, so the one being chosen is * told apart from the scale around it. */ highlight?: boolean; /** Styles that band. */ highlightClassName?: string; /** * What the top and bottom of the window fade into — a theme token name, or * any colour. It has to be told: the fade is painted, so it can only be the * right colour if it is the colour of whatever is behind the window. * Defaults to `--color-background`; pass `--color-card` inside a card. * * `false` turns the fade off, for a window on a surface that is not one flat * colour. */ fadeColor?: string | false; enabled?: boolean; } /** * A column of values scrolling past a window, coming to rest on one. * * The difference from `Sliding` is what the reader is being told. An odometer * says *this number changed*; a column that scrolls past its neighbours says * *this number was chosen from a scale*, and the values either side of it are * the scale. Reach for it for a target, a threshold, a picked quantity — and * for a plain change of value, reach for the other one. */ function TextAnimationScrolling({ className, value, step = 1, around = 2, duration, delay, formatOptions, textClassName, size, weight, highlight = false, highlightClassName, fadeColor = '--color-background', enabled, ...props }: TextAnimationScrollingProps) { const span = useShared('duration', duration, 1400); const wait = useShared('delay', delay, 0); const still = useStill(useShared('enabled', enabled, true)); const { column, highlight: band } = textAnimationVariants(); const token = useCSSVariable( typeof fadeColor === 'string' && fadeColor.startsWith('--') ? fadeColor : '--color-background' ); const resolvedToken = typeof token === 'string' ? token : undefined; const fade = fadeColor === false ? undefined : (fadeColor.startsWith('--') ? resolvedToken : fadeColor) ?? resolvedToken; const format = useFormatter(0, formatOptions); // The window shows `around` either side of the resting value, so the run // starts that many steps below zero and ends that many above the target. const values = useMemo(() => { const out: number[] = []; for (let i = -around; value + around * step >= i * step; i += 1) { out.push(i * step); if (out.length > 400) break; } return out; }, [around, step, value]); const rows = around * 2 + 1; /* * The window shows `rows` values and the chosen one belongs in the middle of * them, so the column comes to rest `around` short of the target's own index. * Landing on the index itself would put the answer against the top edge with * the whole scale below it, which reads as having overshot. */ const found = values.indexOf(value); const target = Math.max(0, (found >= 0 ? found : values.length - 1) - around); const offset = useSharedValue(still ? target : 0); useEffect(() => { if (still) { cancelAnimation(offset); offset.value = target; return; } offset.value = 0; const timer = setTimeout(() => { offset.value = withTiming(target, { duration: span, easing: Easing.out(Easing.cubic), }); }, wait); return () => { clearTimeout(timer); cancelAnimation(offset); }; }, [offset, span, still, target, wait]); const style = useAnimatedStyle(() => ({ transform: [{ translateY: `${(-offset.value / values.length) * 100}%` }], })); return ( {/* `rows` invisible values in flow: the window is as tall as the number of rows asked for, at whatever height the font makes them. */} {Array.from({ length: rows }, (_, i) => ( 0 ))} {/* Behind the column, so the value reads on top of it rather than through it. One row tall and in the middle, which is where the run comes to rest. */} {highlight ? ( ) : null} {values.map((entry) => ( {format(entry)} ))} {/* * The neighbours have to recede or the window is a list of five numbers * with no answer in it. Fading the edges into the surface is what says * the column continues past them — and it is painted rather than masked * because a mask needs a native module this component would otherwise * not want. */} {fade ? ( <> ) : null} ); } TextAnimationScrolling.displayName = 'TextAnimation.Scrolling'; export const TextAnimation = Object.assign(TextAnimationRoot, { Typing: TextAnimationTyping, Rotating: TextAnimationRotating, Counting: TextAnimationCounting, Sliding: TextAnimationSliding, Scrolling: TextAnimationScrolling, });