{"slug":"loading-states","name":"Agent Loading States","description":"Shimmers, cycling reasoning text and a progress timer.","group":"AI agents","detail_url":"https://beui.dev/native/r/loading-states","page_url":"https://beui.dev/react-native/loading-states","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/loading-states?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/loading-states","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/loading-states/agent-progress.tsx","content":"import { useEffect, useState } from \"react\";\nimport { StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  Easing,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\n\nconst CYCLE = 1550;\nconst STAGGER = 140;\nconst GLYPH = 20;\nconst GAP = 2;\nconst CELL = (GLYPH - GAP * 2) / 3;\nconst EASE = Easing.bezier(0.77, 0, 0.175, 1);\n\nfunction format(seconds: number) {\n  if (seconds < 60) return `${seconds.toFixed(1)}s`;\n  const minutes = Math.floor(seconds / 60);\n  return `${minutes}m ${(seconds - minutes * 60).toFixed(1)}s`;\n}\n\nfunction Cell({\n  index,\n  color,\n  reduce,\n  running,\n}: {\n  index: number;\n  color: string;\n  reduce: boolean;\n  running: boolean;\n}) {\n  // 0 → 1 → 0 each cycle: dim and small, then full, then back.\n  const pulse = useSharedValue(0);\n\n  useEffect(() => {\n    if (!running) {\n      cancelAnimation(pulse);\n      pulse.value = withTiming(1, { duration: 200 });\n      return;\n    }\n    pulse.value = 0;\n    pulse.value = withDelay(\n      index * STAGGER,\n      withRepeat(\n        withTiming(1, {\n          duration: CYCLE / 2,\n          easing: EASE,\n          reduceMotion: ReduceMotion.Never,\n        }),\n        -1,\n        true,\n      ),\n    );\n    return () => cancelAnimation(pulse);\n  }, [index, pulse, running]);\n\n  const style = useAnimatedStyle(() => {\n    const p = pulse.value;\n    return reduce\n      ? { opacity: 0.35 + p * 0.45 }\n      : { opacity: 0.28 + p * 0.72, transform: [{ scale: 0.72 + p * 0.28 }] };\n  }, [reduce]);\n\n  return (\n    <Animated.View style={[styles.cell, { backgroundColor: color }, style]} />\n  );\n}\n\nexport type AgentProgressProps = Omit<ViewProps, \"children\"> & {\n  /** Defaults to \"Churning\". */\n  label?: string;\n  /** Controlled elapsed time in seconds. Omit it to count from `initialSeconds`. */\n  elapsedSeconds?: number;\n  /** Where the internal timer starts. Defaults to 0. */\n  initialSeconds?: number;\n  /** Pauses the internal timer and the glyph. Defaults to true. */\n  running?: boolean;\n  /** Text size and color utilities, such as `text-base`. */\n  className?: string;\n  /** Holds the glyph still, independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/**\n * A pulsing 3×3 glyph, a working label and an elapsed timer, for long agent\n * runs. Screen readers hear the label once, not every tick.\n */\nexport function AgentProgress({\n  label = \"Churning\",\n  elapsedSeconds,\n  initialSeconds = 0,\n  running = true,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: AgentProgressProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const controlled = elapsedSeconds !== undefined;\n  const [internal, setInternal] = useState(initialSeconds);\n  const { color } = useResolveClassNames(\"text-muted\");\n  const tint = String(color ?? \"gray\");\n  const active = controlled ? true : running;\n\n  // Restarts only when the timer starts or stops; `internal` seeds the start.\n  useEffect(() => {\n    if (controlled || !running) return;\n    const startedAt = Date.now() - internal * 1000;\n    const timer = setInterval(\n      () => setInternal((Date.now() - startedAt) / 1000),\n      100,\n    );\n    return () => clearInterval(timer);\n  }, [controlled, running]);\n\n  return (\n    <View\n      accessible\n      accessibilityRole=\"progressbar\"\n      accessibilityLabel={accessibilityLabel ?? `${label}, in progress`}\n      style={styles.row}\n      {...props}\n    >\n      <View\n        style={styles.glyph}\n        importantForAccessibility=\"no-hide-descendants\"\n      >\n        {Array.from({ length: 9 }, (_, index) => (\n          <Cell\n            key={index}\n            index={index}\n            color={tint}\n            reduce={reduce}\n            running={active}\n          />\n        ))}\n      </View>\n      <Text className={`text-sm font-medium text-muted ${className}`}>\n        {label}\n      </Text>\n      <Text\n        className={`font-mono text-sm text-muted/70 ${className}`}\n        style={styles.tabular}\n      >\n        {format(controlled ? elapsedSeconds : internal)}\n      </Text>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: \"row\", alignItems: \"center\", gap: 12 },\n  glyph: {\n    width: GLYPH,\n    height: GLYPH,\n    flexDirection: \"row\",\n    flexWrap: \"wrap\",\n    gap: GAP,\n  },\n  cell: { width: CELL, height: CELL, borderRadius: 1 },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n});\n","type":"component"},{"path":"src/components/loading-states/index.ts","content":"export type { AgentProgressProps } from \"./agent-progress\";\nexport { AgentProgress } from \"./agent-progress\";\nexport type {\n  ReasoningTextProps,\n  ReasoningTextVariant,\n} from \"./reasoning-text\";\nexport { ReasoningText } from \"./reasoning-text\";\nexport type { ThinkingShimmerProps } from \"./thinking-shimmer\";\nexport { ThinkingShimmer } from \"./thinking-shimmer\";\n","type":"component"},{"path":"src/components/loading-states/reasoning-text.tsx","content":"import { type ReactNode, useEffect, useState } from \"react\";\nimport { Platform, StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { CascadeText } from \"../../primitives/cascade-text\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport { TextScramble } from \"../text-animation/text-scramble\";\n\nexport type ReasoningTextVariant = \"cascade\" | \"swap\" | \"scramble\";\n\nconst DEFAULT_PHRASES = [\n  \"Thinking\",\n  \"Reading the context\",\n  \"Connecting the details\",\n  \"Forming a response\",\n];\nconst SWAP = { duration: 200, easing: EASE_OUT };\nconst SWAP_REDUCED = { duration: 120 };\nconst SWAP_DISTANCE = 3;\n\n/** Progress runs 0 (below) → 1 (resting) → 2 (gone above). */\nfunction SwapLayer({\n  text,\n  appear,\n  exiting,\n  reduce,\n  className,\n}: {\n  text: string;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  className: string;\n}) {\n  const progress = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    progress.value = withTiming(exiting ? 2 : 1, reduce ? SWAP_REDUCED : SWAP);\n  }, [exiting, progress, reduce]);\n\n  const style = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: p <= 1 ? p : 2 - p,\n      transform: [{ translateY: reduce ? 0 : (1 - p) * SWAP_DISTANCE }],\n    };\n  }, [reduce]);\n\n  return (\n    <Animated.View style={[styles.layer, style]}>\n      <Text numberOfLines={1} className={className}>\n        {text}\n      </Text>\n    </Animated.View>\n  );\n}\n\nfunction Swap({\n  text,\n  index,\n  reduce,\n  className,\n}: {\n  text: string;\n  index: number;\n  reduce: boolean;\n  className: string;\n}) {\n  const items = usePresence(`${index}`, text, () =>\n    reduce ? SWAP_REDUCED.duration + 40 : SWAP.duration + 40,\n  );\n  return items.map((item) => (\n    <SwapLayer\n      key={item.key}\n      text={item.value}\n      appear={item.appear}\n      exiting={item.exiting}\n      reduce={reduce}\n      className={className}\n    />\n  ));\n}\n\nconst SPINNER = [\"|\", \"/\", \"-\", \"\\\\\"];\nconst SPINNER_STEP = 200;\n\n/** A terminal line spinner set in the phrase's own type, so they share a baseline. */\nfunction Spinner({\n  className,\n  reduce,\n}: {\n  className: string;\n  reduce: boolean;\n}) {\n  const [frame, setFrame] = useState(0);\n  useEffect(() => {\n    // Reduced motion slows the swap rather than stopping it; nothing moves.\n    const timer = setInterval(\n      () => setFrame((current) => (current + 1) % SPINNER.length),\n      reduce ? SPINNER_STEP * 2.5 : SPINNER_STEP,\n    );\n    return () => clearInterval(timer);\n  }, [reduce]);\n  return (\n    <Text className={className} style={styles.spinner}>\n      {SPINNER[frame]}\n    </Text>\n  );\n}\n\nexport type ReasoningTextProps = Omit<ViewProps, \"children\"> & {\n  /** Status phrases, cycled in order. An ellipsis is added to each. */\n  phrases?: string[];\n  /** How one phrase gives way to the next. Defaults to \"cascade\". */\n  variant?: ReasoningTextVariant;\n  /** Milliseconds each phrase shows. Defaults to 1800, at least 600. */\n  interval?: number;\n  /** Replaces the spinning ASCII indicator. Pass `null` to hide it. */\n  indicator?: ReactNode;\n  /** Text size utilities, such as `text-base`. */\n  className?: string;\n  /** Swaps phrases without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/**\n * A status line that cycles through what an agent is doing: letters roll in,\n * phrases swap, or glyphs scramble into place. The longest phrase reserves\n * the width, so nothing around it shifts.\n */\nexport function ReasoningText({\n  phrases = DEFAULT_PHRASES,\n  variant = \"cascade\",\n  interval = 1800,\n  indicator,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: ReasoningTextProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [index, setIndex] = useState(0);\n  const count = phrases.length;\n  const step = Math.max(600, interval);\n\n  useEffect(() => {\n    if (count < 2) return;\n    const timer = setInterval(() => setIndex((i) => (i + 1) % count), step);\n    return () => clearInterval(timer);\n  }, [count, step]);\n\n  const phrase = phrases[index % Math.max(count, 1)] ?? \"\";\n  const text = `${phrase}…`;\n  const textClass = `text-sm font-medium text-muted ${className}`;\n\n  return (\n    <View\n      accessible\n      accessibilityLiveRegion=\"polite\"\n      accessibilityLabel={accessibilityLabel ?? phrase}\n      style={styles.row}\n      {...props}\n    >\n      {indicator === undefined ? (\n        <Spinner className={textClass} reduce={reduce} />\n      ) : (\n        indicator\n      )}\n      <View style={[styles.slot, variant !== \"swap\" && styles.overflow]}>\n        {/* Lays out every phrase, invisibly, so the slot is as wide as the widest. */}\n        <View style={styles.reserve}>\n          {phrases.map((candidate, position) => (\n            <Text\n              key={`${position}-${candidate}`}\n              numberOfLines={1}\n              className={textClass}\n              style={position > 0 && styles.collapsed}\n            >\n              {`${candidate}…`}\n            </Text>\n          ))}\n        </View>\n        {variant === \"cascade\" ? (\n          <View style={styles.layer}>\n            <CascadeText\n              textKey={`${index}`}\n              text={text}\n              className={textClass}\n              reduce={reduce}\n            />\n          </View>\n        ) : variant === \"swap\" ? (\n          <Swap\n            text={text}\n            index={index}\n            reduce={reduce}\n            className={textClass}\n          />\n        ) : (\n          <View style={styles.layer}>\n            <TextScramble\n              text={text}\n              disableAnimation={reduce}\n              className={textClass}\n              style={styles.tabular}\n            />\n          </View>\n        )}\n      </View>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: \"row\", alignItems: \"center\", gap: 8 },\n  slot: { overflow: \"hidden\" },\n  reserve: { opacity: 0 },\n  collapsed: { height: 0 },\n  // Letter-by-letter layout and scrambling glyphs can run a little wider than\n  // the kerned phrase that reserves the width; let them.\n  overflow: { overflow: \"visible\" },\n  layer: {\n    position: \"absolute\",\n    left: 0,\n    right: 0,\n    top: 0,\n    bottom: 0,\n    justifyContent: \"center\",\n  },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n  spinner: {\n    width: 12,\n    textAlign: \"center\",\n    fontFamily: Platform.select({ ios: \"Menlo\", default: \"monospace\" }),\n  },\n});\n","type":"component"},{"path":"src/components/loading-states/thinking-shimmer.tsx","content":"import {\n  TextShimmer,\n  type TextShimmerProps,\n} from \"../text-animation/text-shimmer\";\n\nexport type ThinkingShimmerProps = Omit<TextShimmerProps, \"children\"> & {\n  /** Defaults to \"Thinking…\". */\n  children?: string;\n};\n\n/** A highlight sweeping across a muted status line while an agent thinks. */\nexport function ThinkingShimmer({\n  children = \"Thinking…\",\n  duration = 1800,\n  className = \"\",\n  ...props\n}: ThinkingShimmerProps) {\n  return (\n    <TextShimmer\n      duration={duration}\n      className={`text-sm font-medium ${className}`}\n      {...props}\n    >\n      {children}\n    </TextShimmer>\n  );\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":"shared"},{"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":"shared"},{"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.\n// Latin plus general punctuation (dashes, quotes, the ellipsis), none of which shape.\nconst CASCADE_SAFE = /^[\\u0020-\\u007e\\u00a0-\\u024f\\u2000-\\u206f]+$/;\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.\n// Latin plus general punctuation (dashes, quotes, the ellipsis), none of which shape.\nconst SPLIT_SAFE = /^[\\u0020-\\u007e\\u00a0-\\u024f\\u2000-\\u206f]+$/;\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  /* Tailwind's default monospace stack isn't a font React Native can load. */\n  :root {\n    @variant ios {\n      --font-mono: Menlo;\n    }\n  }\n\n  :root {\n    @variant android {\n      --font-mono: monospace;\n    }\n  }\n}\n","type":"theme"}]}