import { useEffect } from 'react' import { LayoutChangeEvent, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native' import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' const STAGGER_MS = 45 const LETTER_DURATION_MS = 320 // The one formula every consumer's own follow-on animation (a paddle rally, a bouncing puck, a // looping trail) needs in order to time its own entrance off "once the letters have mostly // landed" - five apps (AirHockey, BoxHockey, Pong, LightCycles, Snake) had each independently // re-derived `(letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS` locally as their own // `TOTAL_STAGGER_MS` constant. Exported as a pure function, not a component return value, so a // caller can compute it once, outside render, the same way every one of those apps already did. // `letterCount` is the TOTAL number of letters actually animated, not necessarily a single // StaggeredWord's own `word.length` - see `startIndex` below for a multi-word wordmark, where a // second word's letters continue the same overall stagger sequence. export function getStaggeredWordDuration(letterCount: number): number { return (letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS } interface AnimatedLetterProps { char: string index: number color: string reducedMotion: boolean fontFamily: string fontSize: number lineHeight: number fontWeight?: TextStyle['fontWeight'] letterSpacing?: number } function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }: AnimatedLetterProps) { const progress = useSharedValue(reducedMotion ? 1 : 0) useEffect(() => { if (reducedMotion) return progress.value = withDelay(index * STAGGER_MS, withTiming(1, { duration: LETTER_DURATION_MS, easing: Easing.out(Easing.back(1.5)) })) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reducedMotion]) const style = useAnimatedStyle(() => ({ opacity: progress.value, transform: [{ translateY: (1 - progress.value) * 12 }, { scale: 0.85 + progress.value * 0.15 }] })) return ( {char} ) } export interface StaggeredWordProps { word: string color: string fontFamily: string fontSize: number lineHeight: number // Whether to skip the stagger entirely and render every letter already fully settled - wire // straight from react-native-reanimated's own useReducedMotion() at the call site rather than // this component calling it internally, so a caller composing several StaggeredWords (see // `startIndex` below) only needs the one hook call, not one per word. reducedMotion: boolean // Continues the stagger sequence from a larger overall wordmark this word is only one line/part // of - this word's own first letter animates at index `startIndex`, its second at // `startIndex + 1`, and so on. Defaults to 0 for a standalone single-word wordmark (Snake's // "SNAKE", Pong's "PONG"); a caller stacking multiple words (AirHockey's "AIR"/"HOCKEY", // BoxHockey's "BOX"/"HOCKEY", LightCycles' "Light"/"Cycles") passes the running total of letters // already animated by earlier words, so the whole wordmark reads as one continuous cascade // instead of each word restarting its own stagger from zero. startIndex?: number // Every consumer this component was extracted from hardcoded bold letters (baked into a fixed // StyleSheet entry, not a prop) - kept as the default here rather than a fixed style, so the one // consumer that doesn't want it (LightCycles, whose own registered display font is already // sufficiently bold on its own) can opt out with `fontWeight='normal'` instead of this component // forcing it unconditionally. fontWeight?: TextStyle['fontWeight'] // Unset by default - only AirHockey/BoxHockey/Pong's shared MONO_FONT-based letter style actually // wants extra tracking; Snake's/LightCycles' own theme fonts don't. letterSpacing?: number // The wrapping row's own style - e.g. LightCycles' own vertical offset for its second word // beneath its first. style?: StyleProp // Lets a caller measure this word's own rendered box (e.g. to size/position a follow-on canvas // overlay the way Snake's HeroSnakeTrail or LightCycles' HeroTitleTrails do) the same way it // would measure any other plain View. onLayout?: (e: LayoutChangeEvent) => void } // One row of letters staggering in on mount (or rendered already fully settled under reduced // motion) - the "AnimatedLetter" building block five apps' own hero-title screens // (AirHockey/BoxHockey/Pong/LightCycles/Snake's own HeroTitle.tsx/AnimatedHeroTitle.tsx) had each // hand-rolled independently, identical down to the same 45ms stagger, 320ms per-letter duration, // and back-ease overshoot. A single word/line on its own, or one of several stacked/side-by-side // words sharing one overall stagger sequence via `startIndex` - this component only ever renders // one row of letters; a caller composing more than one word renders more than one StaggeredWord // (see `startIndex`'s own doc), the same way every one of those five apps' own `Word` helper // already did internally. export function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex = 0, fontWeight = 'bold', letterSpacing, style, onLayout }: StaggeredWordProps) { return ( {word.split('').map((char, i) => ( ))} ) } const styles = StyleSheet.create({ row: { flexDirection: 'row' } })