{"slug":"text-animation","name":"Text Animation","description":"Reveals, sweeps, shimmers, cascades and scrambles.","group":"Motion","detail_url":"https://beui.dev/native/r/text-animation","page_url":"https://beui.dev/react-native/text-animation","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/text-animation?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/text-animation","libraries":["react","react-native","react-native-reanimated","uniwind"],"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/text-animation/chromatic-text-reveal.tsx","content":"import { useEffect, useRef, useState } from \"react\";\nimport { StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { GlyphRow } from \"../../primitives/glyph-row\";\nimport { EASE_IN_OUT, EASE_OUT } from \"../../styles/motion\";\n\nconst PALETTE = [\"#60a5fa\", \"#818cf8\", \"#c084fc\", \"#fb7185\", \"#fbbf24\"];\n// The colored trail spans 14% of the word either side of the sweep.\nconst TRAIL = 0.14;\nconst SWEEP_START = -TRAIL;\nconst SWEEP_END = 1 + TRAIL;\n\nexport type ChromaticTextRevealProps = Omit<ViewProps, \"children\"> & {\n  /** Fixed sentence fragment before the changing word. */\n  prefix: string;\n  /** Words revealed one after another after the prefix. */\n  words: string[];\n  /** Colors along the moving chromatic edge. */\n  colors?: string[];\n  /** Sweep duration in milliseconds. Defaults to 1200. */\n  duration?: number;\n  /** Delay before each sweep in milliseconds. */\n  delay?: number;\n  /** Rest after a word finishes revealing, in milliseconds. Defaults to 800. */\n  pauseDuration?: number;\n  /** Returns to the first word after the last. Defaults to true. */\n  loop?: boolean;\n  /** Text style for the whole sentence, such as `text-3xl font-semibold`. */\n  className?: string;\n  /** Disables sweeping and cycling independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\nfunction Word({\n  word,\n  className,\n  palette,\n  foreground,\n  duration,\n  delay,\n  reduce,\n  onRevealed,\n}: {\n  word: string;\n  className: string;\n  palette: string[];\n  foreground: string;\n  duration: number;\n  delay: number;\n  reduce: boolean;\n  onRevealed: () => void;\n}) {\n  const sweep = useSharedValue(reduce ? SWEEP_END : SWEEP_START);\n  // A ref keeps a new callback identity from restarting the sweep.\n  const revealedRef = useRef(onRevealed);\n  revealedRef.current = onRevealed;\n  const enter = useSharedValue(reduce ? 1 : 0);\n\n  useEffect(() => {\n    if (reduce) return;\n    enter.value = withTiming(1, { duration: 360, easing: EASE_OUT });\n    sweep.value = withDelay(\n      delay,\n      withTiming(SWEEP_END, { duration, easing: EASE_IN_OUT }),\n    );\n    const timer = setTimeout(() => revealedRef.current(), delay + duration);\n    return () => {\n      clearTimeout(timer);\n      cancelAnimation(sweep);\n    };\n  }, [delay, duration, enter, reduce, sweep]);\n\n  const stops = palette.map((_, index) =>\n    palette.length === 1\n      ? 0\n      : -TRAIL + (index / (palette.length - 1)) * TRAIL * 2,\n  );\n\n  // Behind the sweep the word is solid; inside the trail it takes the palette;\n  // ahead of it the word is still invisible.\n  const colorAt = (s: number, position: number) => {\n    \"worklet\";\n    const offset = position - s;\n    if (offset < -TRAIL) return { color: foreground, opacity: 1 };\n    if (offset > TRAIL) return { color: foreground, opacity: 0 };\n    return {\n      color:\n        palette.length === 1\n          ? (palette[0] ?? foreground)\n          : interpolateColor(offset, stops, palette),\n      opacity: 1,\n    };\n  };\n\n  const enterStyle = useAnimatedStyle(() => ({\n    opacity: 0.56 + 0.44 * Math.min(1, enter.value * (360 / 280)),\n    transform: [{ translateY: 5 * (1 - enter.value) }],\n  }));\n\n  return (\n    <Animated.View style={[styles.word, enterStyle]}>\n      <GlyphRow\n        text={word}\n        className={className}\n        progress={sweep}\n        colorAt={colorAt}\n      />\n    </Animated.View>\n  );\n}\n\n/** A sentence whose final word is revealed by a moving chromatic sweep, then cycles. */\nexport function ChromaticTextReveal({\n  prefix,\n  words,\n  colors = PALETTE,\n  duration = 1200,\n  delay = 0,\n  pauseDuration = 800,\n  loop = true,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: ChromaticTextRevealProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const [index, setIndex] = useState(0);\n  const [cycle, setCycle] = useState(0);\n  const palette = colors.length > 0 ? colors : PALETTE;\n  const active = words.length > 0 ? index % words.length : 0;\n  const word = words[active] ?? \"\";\n  const sizing = Array.from(new Set(words));\n\n  const [revealed, setRevealed] = useState(false);\n\n  useEffect(() => {\n    if (!revealed) return;\n    const last = active === words.length - 1;\n    if (reduce || words.length < 2 || (last && !loop)) return;\n    const timer = setTimeout(() => {\n      setRevealed(false);\n      setIndex((current) => (current + 1) % words.length);\n      setCycle((current) => current + 1);\n    }, pauseDuration);\n    return () => clearTimeout(timer);\n  }, [active, loop, pauseDuration, reduce, revealed, words.length]);\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityRole=\"text\"\n      accessibilityLabel={accessibilityLabel ?? `${prefix} ${word}`}\n      style={[styles.sentence, props.style]}\n    >\n      <Text className={className}>\n        {prefix}\n        {words.length > 0 ? \" \" : null}\n      </Text>\n      {words.length > 0 ? (\n        <View>\n          {/* The widest word reserves space so the sentence never reflows. */}\n          {sizing.map((candidate) => (\n            <Text key={candidate} className={className} style={styles.sizer}>\n              {candidate}\n            </Text>\n          ))}\n          <Word\n            key={`${word}-${cycle}`}\n            word={word}\n            className={className}\n            palette={palette}\n            foreground={String(foreground ?? \"black\")}\n            duration={duration}\n            delay={delay}\n            reduce={reduce}\n            onRevealed={() => setRevealed(true)}\n          />\n        </View>\n      ) : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  sentence: {\n    flexDirection: \"row\",\n    alignItems: \"flex-start\",\n    alignSelf: \"flex-start\",\n  },\n  // Stacked invisibly in one cell; the tallest and widest sets the size.\n  sizer: { opacity: 0, height: 0, overflow: \"hidden\" },\n  word: { position: \"absolute\", left: 0, top: 0 },\n});\n","type":"component"},{"path":"src/components/text-animation/index.ts","content":"export type { ChromaticTextRevealProps } from \"./chromatic-text-reveal\";\nexport { ChromaticTextReveal } from \"./chromatic-text-reveal\";\nexport type { TextCascadeProps } from \"./text-cascade\";\nexport { TextCascade } from \"./text-cascade\";\nexport type { TextRevealProps } from \"./text-reveal\";\nexport { TextReveal } from \"./text-reveal\";\nexport type { TextScrambleProps } from \"./text-scramble\";\nexport { TextScramble } from \"./text-scramble\";\nexport type { TextShimmerProps } from \"./text-shimmer\";\nexport { TextShimmer } from \"./text-shimmer\";\n","type":"component"},{"path":"src/components/text-animation/text-cascade.tsx","content":"import { View, type ViewProps } from \"react-native\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { CascadeText } from \"../../primitives/cascade-text\";\n\nexport type TextCascadeProps = Omit<ViewProps, \"children\"> & {\n  /** Current text. Changing it cascades the letters to the new value. */\n  text: string;\n  /** Text style for every letter. */\n  className?: string;\n  /** Disables the cascade independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/**\n * Letter-by-letter slot roll for standalone text: old letters roll away as\n * new ones land, left to right. The same motion as StatefulButton's labels.\n */\nexport function TextCascade({\n  text,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: TextCascadeProps) {\n  const reducedMotion = useReducedMotionPreference();\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityRole=\"text\"\n      accessibilityLabel={accessibilityLabel ?? text}\n      style={[{ alignSelf: \"flex-start\" }, props.style]}\n    >\n      <CascadeText\n        textKey={text}\n        text={text}\n        className={className}\n        reduce={reducedMotion || disableAnimation}\n      />\n    </View>\n  );\n}\n","type":"component"},{"path":"src/components/text-animation/text-reveal.tsx","content":"import { useEffect } from \"react\";\nimport { StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { EASE_OUT } from \"../../styles/motion\";\n\nconst DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };\n\ntype Offset = number | `${number}%`;\n\nexport type TextRevealProps = Omit<ViewProps, \"children\"> & {\n  /** One string, or several lines. */\n  text: string | string[];\n  /** Animates whole words (the default) or single characters. */\n  split?: \"word\" | \"char\";\n  /** Delay between units in milliseconds. Defaults to 90. */\n  stagger?: number;\n  /** Delay before the first unit in milliseconds. */\n  delay?: number;\n  /** Rise distance: points, or a percentage of the line height. Defaults to \"40%\". */\n  yOffset?: Offset;\n  spring?: { stiffness?: number; damping?: number; mass?: number };\n  /** Text style for every unit, such as `text-3xl font-semibold`. */\n  className?: string;\n  /** Disables the rise independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\ntype WordGroup = { text: string; trailing: string };\n\n/** Splits a line into words, each carrying the whitespace that follows it. */\nfunction toWordGroups(line: string): WordGroup[] {\n  const chunks = line.match(/\\S+\\s*|\\s+/g) ?? [];\n  return chunks.map((chunk) => {\n    const text = chunk.replace(/\\s+$/, \"\");\n    return { text, trailing: chunk.slice(text.length) };\n  });\n}\n\n// Whitespace-only Text can measure zero-width on Android.\nconst keepSpaces = (value: string) => value.replace(/ /g, \" \");\n\nfunction Unit({\n  text,\n  delay,\n  offset,\n  spring,\n  reduce,\n  className,\n}: {\n  text: string;\n  delay: number;\n  offset: Offset;\n  spring: typeof DEFAULT_SPRING;\n  reduce: boolean;\n  className: string;\n}) {\n  const rise = useSharedValue(0);\n  const fade = useSharedValue(0);\n  const height = useSharedValue(0);\n\n  useEffect(() => {\n    if (reduce) {\n      fade.value = withDelay(\n        delay * 0.3,\n        withTiming(1, { duration: 250, easing: EASE_OUT }),\n      );\n      rise.value = 1;\n      return;\n    }\n    const { stiffness, damping, mass } = spring;\n    rise.value = withDelay(delay, withSpring(1, { stiffness, damping, mass }));\n    fade.value = withDelay(\n      delay,\n      withTiming(1, { duration: 700, easing: EASE_OUT }),\n    );\n    // Depend on the spring's numbers, not its object identity.\n  }, [\n    delay,\n    fade,\n    reduce,\n    rise,\n    spring.stiffness,\n    spring.damping,\n    spring.mass,\n  ]);\n\n  const percent = typeof offset === \"string\" ? Number.parseFloat(offset) : null;\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const distance =\n      percent === null ? (offset as number) : (height.value * percent) / 100;\n    return {\n      opacity: fade.value,\n      transform: [{ translateY: (1 - rise.value) * distance }],\n    };\n  });\n\n  return (\n    <Animated.View\n      style={animatedStyle}\n      onLayout={({ nativeEvent: { layout } }) => {\n        height.value = layout.height;\n      }}\n    >\n      <Text className={className}>{keepSpaces(text)}</Text>\n    </Animated.View>\n  );\n}\n\n/** Words or characters that rise into place on a spring, one after another. */\nexport function TextReveal({\n  text,\n  split = \"word\",\n  stagger = 90,\n  delay = 0,\n  yOffset = \"40%\",\n  spring,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: TextRevealProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const lines = Array.isArray(text) ? text : [text];\n  const physics = { ...DEFAULT_SPRING, ...spring };\n  let unitIndex = 0;\n\n  const renderUnit = (unit: string, key: string) => {\n    const unitDelay = delay + unitIndex * stagger;\n    unitIndex += 1;\n    return (\n      <Unit\n        key={key}\n        text={unit}\n        delay={unitDelay}\n        offset={yOffset}\n        spring={physics}\n        reduce={reduce}\n        className={className}\n      />\n    );\n  };\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityLabel={accessibilityLabel ?? lines.join(\"\\n\")}\n    >\n      {lines.map((line, lineIndex) => (\n        // Lines and units are positional; the whole tree remounts with new text.\n        <View key={`${lineIndex}-${line}`} style={styles.line}>\n          {toWordGroups(line).map((group, groupIndex) => {\n            const whole = group.text + group.trailing;\n            const key = `${groupIndex}-${whole}`;\n            if (split !== \"char\") return renderUnit(whole, key);\n            // Each word stays together so lines wrap between words, not inside them.\n            return (\n              <View key={key} style={styles.word}>\n                {Array.from(whole).map((char, charIndex) =>\n                  renderUnit(char, `${charIndex}-${char}`),\n                )}\n              </View>\n            );\n          })}\n        </View>\n      ))}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  line: { flexDirection: \"row\", flexWrap: \"wrap\" },\n  word: { flexDirection: \"row\" },\n});\n","type":"component"},{"path":"src/components/text-animation/text-scramble.tsx","content":"import { useEffect, useRef, useState } from \"react\";\nimport { Text, type TextProps } from \"react-native\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\n\nconst DEFAULT_GLYPHS = \"ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\";\n// Scrambled glyphs change at most this often, in milliseconds.\nconst FRAME = 40;\n\nexport type TextScrambleProps = Omit<TextProps, \"children\"> & {\n  /** Final text. Changing it scrambles toward the new value. */\n  text: string;\n  /** Total duration in milliseconds. Defaults to 32 ms per character, within 420–760. */\n  duration?: number;\n  /** Characters sampled while positions are unresolved. */\n  glyphs?: string;\n  /** Disables scrambling independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n};\n\n/** Characters scramble through random glyphs and settle left to right on `text`. */\nexport function TextScramble({\n  text,\n  duration,\n  glyphs = DEFAULT_GLYPHS,\n  disableAnimation = false,\n  className = \"\",\n  accessibilityLabel,\n  ...props\n}: TextScrambleProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const [display, setDisplay] = useState(text);\n  const mounted = useRef(false);\n\n  useEffect(() => {\n    // The first text appears as is; later changes scramble.\n    if (!mounted.current) {\n      mounted.current = true;\n      setDisplay(text);\n      return;\n    }\n    if (!animate || !glyphs) {\n      setDisplay(text);\n      return;\n    }\n\n    const characters = Array.from(text);\n    const total =\n      duration ?? Math.min(760, Math.max(420, characters.length * 32));\n    const startedAt = Date.now();\n    let lastUpdate = 0;\n    let frame = 0;\n\n    const tick = () => {\n      const now = Date.now();\n      if (now - lastUpdate >= FRAME) {\n        lastUpdate = now;\n        const settled = Math.floor(\n          Math.min((now - startedAt) / total, 1) * characters.length,\n        );\n        setDisplay(\n          characters\n            .map((character, index) =>\n              index < settled || character === \" \"\n                ? character\n                : glyphs[Math.floor(Math.random() * glyphs.length)],\n            )\n            .join(\"\"),\n        );\n      }\n      if (now - startedAt < total) frame = requestAnimationFrame(tick);\n      else setDisplay(text);\n    };\n\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [animate, duration, glyphs, text]);\n\n  return (\n    <Text\n      {...props}\n      // Screen readers get the final text, never the scrambled frames.\n      accessibilityLabel={accessibilityLabel ?? text}\n      className={className}\n    >\n      {animate ? display : text}\n    </Text>\n  );\n}\n","type":"component"},{"path":"src/components/text-animation/text-shimmer.tsx","content":"import { useEffect } from \"react\";\nimport { View, type ViewProps } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  Easing,\n  interpolateColor,\n  ReduceMotion,\n  useSharedValue,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { GlyphRow } from \"../../primitives/glyph-row\";\n\n// The web gradient is twice the text's width with its highlight 40% either side\n// of its middle, swept from 200% to -200% background position. In text widths\n// that is a highlight 0.4 wide whose center travels from -1 to 3.\nconst BAND = 0.4;\nconst SWEEP_FROM = -1;\nconst SWEEP_TO = 3;\n\nexport type TextShimmerProps = Omit<ViewProps, \"children\"> & {\n  children: string;\n  /** One sweep in milliseconds. Defaults to 2500. */\n  duration?: number;\n  /** Text style, such as `text-xl font-semibold`. Colors come from the theme. */\n  className?: string;\n  /** Disables the sweep independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/** A highlight that sweeps across muted text, for loading and thinking states. */\nexport function TextShimmer({\n  children,\n  duration = 2500,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: TextShimmerProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const { color: base } = useResolveClassNames(\"text-muted\");\n  const { color: highlight } = useResolveClassNames(\"text-foreground\");\n  const progress = useSharedValue(0);\n  const baseColor = String(base ?? \"gray\");\n  const highlightColor = String(highlight ?? \"black\");\n\n  useEffect(() => {\n    if (!animate) {\n      cancelAnimation(progress);\n      progress.value = 0;\n      return;\n    }\n    progress.value = 0;\n    progress.value = withRepeat(\n      withTiming(1, {\n        duration,\n        easing: Easing.linear,\n        reduceMotion: ReduceMotion.Never,\n      }),\n      -1,\n    );\n    return () => cancelAnimation(progress);\n  }, [animate, duration, progress]);\n\n  const colorAt = (p: number, position: number) => {\n    \"worklet\";\n    const center = SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * p;\n    const lit = Math.max(0, 1 - Math.abs(position - center) / BAND);\n    return {\n      color: interpolateColor(lit, [0, 1], [baseColor, highlightColor]),\n      opacity: 1,\n    };\n  };\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityRole=\"text\"\n      accessibilityLabel={accessibilityLabel ?? children}\n      style={[{ alignSelf: \"flex-start\" }, props.style]}\n    >\n      {animate ? (\n        <GlyphRow\n          text={children}\n          className={className}\n          progress={progress}\n          colorAt={colorAt}\n        />\n      ) : (\n        <Animated.Text className={className} style={{ color: baseColor }}>\n          {children}\n        </Animated.Text>\n      )}\n    </View>\n  );\n}\n","type":"component"},{"path":"src/hooks/use-presence.ts","content":"import { useEffect, useRef, useState } from \"react\";\n\nexport type PresenceItem<T> = {\n  key: string;\n  value: T;\n  /** False for the item present on first render, so it does not animate in. */\n  appear: boolean;\n  exiting: boolean;\n};\n\n/**\n * Keeps replaced items mounted while they animate out, like AnimatePresence.\n * A key that returns while exiting is revived in place.\n */\nexport function usePresence<T>(\n  key: string | null,\n  value: T,\n  exitDuration: (value: T) => number,\n) {\n  const [items, setItems] = useState<PresenceItem<T>[]>(() =>\n    key === null ? [] : [{ key, value, appear: false, exiting: false }],\n  );\n  const [currentKey, setCurrentKey] = useState(key);\n  const exitDurationRef = useRef(exitDuration);\n  exitDurationRef.current = exitDuration;\n\n  // Adjust during render so the new item and the exits start in the same frame.\n  if (key !== currentKey) {\n    setCurrentKey(key);\n    setItems((previous) => {\n      const next = previous.map((item) =>\n        item.key === key\n          ? { ...item, value, exiting: false }\n          : { ...item, exiting: true },\n      );\n      if (key !== null && !previous.some((item) => item.key === key)) {\n        next.push({ key, value, appear: true, exiting: false });\n      }\n      return next;\n    });\n  }\n\n  const exitingKeys = items\n    .filter((item) => item.exiting)\n    .map((item) => item.key)\n    .join(\"\\n\");\n\n  useEffect(() => {\n    if (!exitingKeys) return;\n    const timers = items\n      .filter((item) => item.exiting)\n      .map((item) =>\n        setTimeout(() => {\n          setItems((previous) =>\n            previous.filter(\n              (candidate) => !(candidate.key === item.key && candidate.exiting),\n            ),\n          );\n        }, exitDurationRef.current(item.value)),\n      );\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n    // Restart removal timers only when the set of exiting items changes.\n  }, [exitingKeys]);\n\n  return items;\n}\n","type":"shared"},{"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/primitives/cascade-text.tsx","content":"import { type ReactNode, useEffect, useState } from \"react\";\nimport { StyleSheet, Text, View } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { usePresence } from \"../hooks/use-presence\";\nimport { motion } from \"../styles/motion\";\n\nconst CASCADE_STAGGER = 25;\nconst TEXT_DISTANCE = 14;\n// Splitting into letters breaks shaping in connected and complex scripts.\nconst CASCADE_SAFE = /^[\\u0020-\\u007e\\u00a0-\\u024f]+$/;\n\nfunction letters(text: string) {\n  // Whitespace-only Text can measure zero-width on Android.\n  return Array.from(text.replace(/ /g, \"\\u00a0\"));\n}\n\nfunction exitDuration(text: string, cascade: boolean) {\n  const stagger = cascade ? (text.length - 1) * CASCADE_STAGGER * 0.5 : 0;\n  return motion.exit.duration + stagger + 40;\n}\n\n/** Progress runs 0 (below) → 1 (resting) → 2 (exited above). */\nfunction Slide({\n  appear,\n  exiting,\n  distance,\n  delay,\n  reduce,\n  children,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  distance: number;\n  delay: number;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const progress = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    if (reduce) {\n      progress.value = withTiming(exiting ? 2 : 1, motion.fade);\n    } else if (exiting) {\n      progress.value = withDelay(delay / 2, withTiming(2, motion.exit));\n    } else {\n      progress.value = withDelay(delay, withSpring(1, motion.swap));\n    }\n  }, [delay, exiting, progress, reduce]);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.min(1, Math.max(0, p <= 1 ? p : 2 - p)),\n      transform: [{ translateY: reduce ? 0 : (1 - p) * distance }],\n    };\n  }, [distance, reduce]);\n\n  return <Animated.View style={animatedStyle}>{children}</Animated.View>;\n}\n\nfunction Label({\n  text,\n  cascade,\n  className,\n}: {\n  text: string;\n  cascade: boolean;\n  className: string;\n}) {\n  return cascade ? (\n    letters(text).map((char, index) => (\n      <Text key={index} className={className}>\n        {char}\n      </Text>\n    ))\n  ) : (\n    <Text numberOfLines={1} className={className}>\n      {text}\n    </Text>\n  );\n}\n\nexport type CascadeTextProps = {\n  /** Changing the key swaps in `text`, even when the text itself repeats. */\n  textKey: string;\n  text: string;\n  /** Text style applied to every letter. */\n  className: string;\n  reduce: boolean;\n};\n\n/**\n * Letter-by-letter slot roll: old letters roll up and away as new ones spring\n * in from below, left to right, while the width springs to fit. Shared by\n * StatefulButton and TextCascade.\n */\nexport function CascadeText({\n  textKey,\n  text,\n  className,\n  reduce,\n}: CascadeTextProps) {\n  const canCascade = (value: string) => !reduce && CASCADE_SAFE.test(value);\n  const items = usePresence(textKey, text, (value) =>\n    reduce ? motion.fade.duration + 40 : exitDuration(value, canCascade(value)),\n  );\n  const [measured, setMeasured] = useState<{\n    width: number;\n    height: number;\n  } | null>(null);\n  const width = useSharedValue(0);\n\n  const widthStyle = useAnimatedStyle(() => ({ width: width.value }));\n  const labelClass = className;\n  const distance = measured ? measured.height * 1.05 : 20;\n\n  return (\n    <Animated.View\n      style={[\n        styles.textSlot,\n        measured && { height: measured.height },\n        measured && widthStyle,\n      ]}\n    >\n      {/* Measures the incoming label with the same per-letter layout it renders with. */}\n      <View style={styles.measureHost}>\n        <View\n          style={styles.row}\n          onLayout={({ nativeEvent: { layout } }) => {\n            if (measured) {\n              width.value = reduce\n                ? layout.width\n                : withSpring(layout.width, motion.swap);\n            } else {\n              width.value = layout.width;\n            }\n            setMeasured({ width: layout.width, height: layout.height });\n          }}\n        >\n          <Label text={text} cascade={canCascade(text)} className={className} />\n        </View>\n      </View>\n\n      {items.map((item) => {\n        const cascade = canCascade(item.value);\n        return (\n          <View key={item.key} style={[styles.row, measured && styles.layer]}>\n            {cascade ? (\n              letters(item.value).map((char, index) => (\n                <Slide\n                  key={index}\n                  appear={item.appear}\n                  exiting={item.exiting}\n                  distance={distance}\n                  delay={index * CASCADE_STAGGER}\n                  reduce={reduce}\n                >\n                  <Text className={labelClass}>{char}</Text>\n                </Slide>\n              ))\n            ) : (\n              <Slide\n                appear={item.appear}\n                exiting={item.exiting}\n                distance={TEXT_DISTANCE}\n                delay={0}\n                reduce={reduce}\n              >\n                <Text numberOfLines={1} className={labelClass}>\n                  {item.value}\n                </Text>\n              </Slide>\n            )}\n          </View>\n        );\n      })}\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  textSlot: { overflow: \"hidden\", flexShrink: 0 },\n  row: { flexDirection: \"row\", alignItems: \"center\" },\n  layer: { position: \"absolute\", left: 0, top: 0 },\n  measureHost: {\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":"shared"},{"path":"src/primitives/glyph-row.tsx","content":"import { StyleSheet, View, type ViewStyle } from \"react-native\";\nimport Animated, {\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n} from \"react-native-reanimated\";\n\n/**\n * Returns a glyph's color for the current progress. `position` is the glyph's\n * center across the row, from 0 at the left edge to 1 at the right. Must be a\n * worklet.\n */\nexport type GlyphColor = (\n  progress: number,\n  position: number,\n) => { color: string; opacity: number };\n\n// Splitting into glyphs breaks shaping in connected and complex scripts.\nconst SPLIT_SAFE = /^[ -~ -ɏ]+$/;\n\nexport type GlyphRowProps = {\n  text: string;\n  /** Text style for every glyph. Its color is replaced by `colorAt`. */\n  className?: string;\n  progress: SharedValue<number>;\n  colorAt: GlyphColor;\n  style?: ViewStyle;\n};\n\nfunction Glyph({\n  char,\n  className,\n  progress,\n  rowWidth,\n  colorAt,\n}: {\n  char: string;\n  className?: string;\n  progress: SharedValue<number>;\n  rowWidth: SharedValue<number>;\n  colorAt: GlyphColor;\n}) {\n  const center = useSharedValue(-1);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const width = rowWidth.value;\n    // Until measured, assume the middle so nothing flashes at the edges.\n    const position =\n      width > 0 && center.value >= 0 ? center.value / width : 0.5;\n    return colorAt(progress.value, position);\n  });\n\n  return (\n    <Animated.Text\n      className={className}\n      style={animatedStyle}\n      onLayout={({ nativeEvent: { layout } }) => {\n        center.value = layout.x + layout.width / 2;\n      }}\n    >\n      {char}\n    </Animated.Text>\n  );\n}\n\n/**\n * Text drawn one glyph at a time so each glyph can take its own animated\n * color, standing in for web gradients clipped to text. Scripts that need\n * shaping render as a single glyph so their letters still join.\n */\nexport function GlyphRow({\n  text,\n  className,\n  progress,\n  colorAt,\n  style,\n}: GlyphRowProps) {\n  const rowWidth = useSharedValue(0);\n  // Whitespace-only Text can measure zero-width on Android.\n  const glyphs = SPLIT_SAFE.test(text)\n    ? Array.from(text.replace(/ /g, \" \"))\n    : [text];\n\n  return (\n    <View\n      style={[styles.row, style]}\n      onLayout={({ nativeEvent: { layout } }) => {\n        rowWidth.value = layout.width;\n      }}\n    >\n      {glyphs.map((char, index) => (\n        <Glyph\n          // Position is the glyph's identity within one string.\n          key={`${index}-${char}`}\n          char={char}\n          className={className}\n          progress={progress}\n          rowWidth={rowWidth}\n          colorAt={colorAt}\n        />\n      ))}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: \"row\", alignItems: \"baseline\" },\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"}]}