{"slug":"loader","name":"Loader","description":"Seventeen indicators from one size prop.","group":"Motion","detail_url":"https://beui.dev/native/r/loader","page_url":"https://beui.dev/react-native/loader","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/loader?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/loader","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/loader/index.ts","content":"export type { LoaderProps, LoaderVariant } from \"./loader\";\nexport { Loader } from \"./loader\";\n","type":"component"},{"path":"src/components/loader/loader.tsx","content":"import { useEffect, useState } from \"react\";\nimport {\n  type ColorValue,\n  Platform,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  Easing,\n  type SharedValue,\n  useAnimatedStyle,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { useKeyframes } from \"./use-keyframes\";\n\nexport type LoaderVariant =\n  | \"spinner\"\n  | \"dots\"\n  | \"bars\"\n  | \"dot-matrix\"\n  | \"dither\"\n  | \"ascii\"\n  | \"ascii-line\"\n  | \"ascii-braille\"\n  | \"ascii-blocks\"\n  | \"ascii-bounce\"\n  | \"morph\"\n  | \"comet\"\n  | \"scramble\"\n  | \"metaballs\"\n  | \"newton\"\n  | \"helix\"\n  | \"percent\";\n\n// Terminal-style frame sets, the loaders CLI agents cycle through.\nconst ASCII_SETS: Partial<Record<LoaderVariant, string[]>> = {\n  ascii: [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n  \"ascii-line\": [\"|\", \"/\", \"-\", \"\\\\\"],\n  \"ascii-braille\": [\"⣾\", \"⣽\", \"⣻\", \"⢿\", \"⡿\", \"⣟\", \"⣯\", \"⣷\"],\n  \"ascii-blocks\": [\n    \"▁\",\n    \"▂\",\n    \"▃\",\n    \"▄\",\n    \"▅\",\n    \"▆\",\n    \"▇\",\n    \"█\",\n    \"▇\",\n    \"▆\",\n    \"▅\",\n    \"▄\",\n    \"▃\",\n    \"▂\",\n  ],\n  \"ascii-bounce\": [\"⠁\", \"⠂\", \"⠄\", \"⡀\", \"⢀\", \"⠠\", \"⠐\", \"⠈\"],\n};\n\nconst MONO = Platform.select({\n  ios: \"Menlo\",\n  android: \"monospace\",\n  default: \"ui-monospace, SFMono-Regular, Menlo, monospace\",\n});\n\n// Reduced motion keeps a calm opacity pulse and drops every transform.\nconst PULSE = [1, 0.4, 1];\nconst PULSE_DURATION = 1400;\n\nexport type LoaderProps = Omit<ViewProps, \"children\"> & {\n  variant?: LoaderVariant;\n  /** Base square size in points. Everything scales from this. Defaults to 32. */\n  size?: number;\n  /** Seconds per animation cycle. Defaults to 1. */\n  speed?: number;\n  /** Spoken name. Defaults to \"Loading\". */\n  label?: string;\n  /** Overrides the theme's foreground color. */\n  color?: ColorValue;\n  /** Color utility, such as `text-primary`. Used when `color` is not set. */\n  className?: string;\n  /** Uses the reduced-motion pulse regardless of the system preference. */\n  disableAnimation?: boolean;\n};\n\ntype PartProps = {\n  size: number;\n  /** Milliseconds per cycle. */\n  cycle: number;\n  reduce: boolean;\n  color: ColorValue;\n};\n\nfunction Dot({\n  size,\n  color,\n  style,\n  square = false,\n}: {\n  size: number;\n  color: ColorValue;\n  style?: object;\n  square?: boolean;\n}) {\n  return (\n    <Animated.View\n      style={[\n        {\n          width: size,\n          height: size,\n          borderRadius: square ? 0 : size / 2,\n          backgroundColor: color,\n        },\n        style,\n      ]}\n    />\n  );\n}\n\nfunction useOpacity(value: SharedValue<number>) {\n  return useAnimatedStyle(() => ({ opacity: value.value }));\n}\n\nfunction Spinner({ size, cycle, reduce, color }: PartProps) {\n  const stroke = Math.max(2, size * 0.09);\n  const turn = useKeyframes([0, 360], {\n    duration: cycle,\n    easing: Easing.linear,\n    enabled: !reduce,\n  });\n  const pulse = useKeyframes(PULSE, {\n    duration: PULSE_DURATION,\n    enabled: reduce,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: pulse.value,\n    // The top border spans 10:30 to 1:30; a 45° start puts it at 12 to 3.\n    transform: [{ rotate: `${45 + turn.value}deg` }],\n  }));\n  const ring = {\n    width: size,\n    height: size,\n    borderRadius: size / 2,\n    borderWidth: stroke,\n  };\n\n  return (\n    <Animated.View style={[{ width: size, height: size }, animatedStyle]}>\n      <View style={[ring, { borderColor: color, opacity: 0.2 }]} />\n      <View\n        style={[\n          ring,\n          styles.overlay,\n          { borderColor: \"transparent\", borderTopColor: color },\n        ]}\n      />\n    </Animated.View>\n  );\n}\n\nfunction BouncingDot({\n  index,\n  size,\n  cycle,\n  reduce,\n  color,\n}: PartProps & { index: number }) {\n  const delay = index * cycle * 0.16;\n  const y = useKeyframes([0, -size * 0.3, 0], {\n    duration: cycle,\n    delay,\n    enabled: !reduce,\n  });\n  const opacity = useKeyframes(reduce ? [0.4, 1, 0.4] : [0.5, 1, 0.5], {\n    duration: cycle,\n    delay,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ translateY: y.value }],\n  }));\n  return <Dot size={size * 0.24} color={color} style={animatedStyle} />;\n}\n\nfunction Dots(props: PartProps) {\n  return (\n    <View style={[styles.row, { gap: props.size * 0.14 }]}>\n      {[0, 1, 2].map((index) => (\n        <BouncingDot key={index} index={index} {...props} />\n      ))}\n    </View>\n  );\n}\n\nfunction Bar({\n  index,\n  size,\n  cycle,\n  reduce,\n  color,\n}: PartProps & { index: number }) {\n  const delay = index * cycle * 0.12;\n  const scale = useKeyframes([0.3, 1, 0.3], {\n    duration: cycle,\n    delay,\n    enabled: !reduce,\n  });\n  const opacity = useKeyframes([0.4, 1, 0.4], {\n    duration: cycle,\n    delay,\n    enabled: reduce,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: reduce ? opacity.value : 1,\n    transform: [{ scaleY: reduce ? 1 : scale.value }],\n  }));\n  const width = size * 0.16;\n  return (\n    <Animated.View\n      style={[\n        {\n          width,\n          height: size,\n          borderRadius: width / 2,\n          backgroundColor: color,\n          transformOrigin: \"bottom\",\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nfunction Bars(props: PartProps) {\n  return (\n    <View style={[styles.row, { gap: props.size * 0.1, height: props.size }]}>\n      {[0, 1, 2, 3].map((index) => (\n        <Bar key={index} index={index} {...props} />\n      ))}\n    </View>\n  );\n}\n\nfunction MatrixCell({\n  delay,\n  dot,\n  cycle,\n  reduce,\n  color,\n}: Omit<PartProps, \"size\"> & { delay: number; dot: number }) {\n  const opacity = useKeyframes(reduce ? [0.3, 1, 0.3] : [0.2, 1, 0.2], {\n    duration: cycle,\n    delay,\n  });\n  const scale = useKeyframes([0.7, 1, 0.7], {\n    duration: cycle,\n    delay,\n    enabled: !reduce,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ scale: reduce ? 1 : scale.value }],\n  }));\n  return <Dot size={dot} color={color} style={animatedStyle} />;\n}\n\nfunction DotMatrix(props: PartProps) {\n  const n = 3;\n  const gap = props.size * 0.14;\n  const dot = (props.size - gap * (n - 1)) / n;\n  return (\n    <View style={[styles.grid, { width: props.size, gap }]}>\n      {Array.from({ length: n * n }, (_, index) => {\n        const x = index % n;\n        const y = Math.floor(index / n);\n        // A diagonal wave: cells light by their distance from the corner.\n        const delay = ((x + y) / (2 * (n - 1))) * props.cycle;\n        return (\n          <MatrixCell\n            key={`${x}-${y}`}\n            delay={delay}\n            dot={dot}\n            cycle={props.cycle}\n            reduce={props.reduce}\n            color={props.color}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\n// The ordered 4×4 Bayer matrix: cells light in this order, like a dissolving halftone.\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];\n\nfunction DitherCell({\n  order,\n  cell,\n  cycle,\n  reduce,\n  color,\n}: Omit<PartProps, \"size\"> & { order: number; cell: number }) {\n  const opacity = useKeyframes(reduce ? [0.3, 1, 0.3] : [0.1, 1, 0.1], {\n    duration: cycle,\n    delay: (order / BAYER.length) * cycle,\n  });\n  return <Dot size={cell} color={color} square style={useOpacity(opacity)} />;\n}\n\nfunction Dither(props: PartProps) {\n  const n = 4;\n  const gap = Math.max(1, props.size * 0.05);\n  const cell = (props.size - gap * (n - 1)) / n;\n  return (\n    <View style={[styles.grid, { width: props.size, gap }]}>\n      {BAYER.map((order) => (\n        <DitherCell\n          key={order}\n          order={order}\n          cell={cell}\n          cycle={props.cycle}\n          reduce={props.reduce}\n          color={props.color}\n        />\n      ))}\n    </View>\n  );\n}\n\nfunction Ascii({\n  frames,\n  size,\n  cycle,\n  reduce,\n  color,\n}: PartProps & { frames: string[] }) {\n  const [frame, setFrame] = useState(0);\n\n  useEffect(() => {\n    // Reduced motion slows the cycle instead of stopping it: it swaps glyphs\n    // rather than moving anything on screen.\n    const step = (reduce ? cycle * 2.5 : cycle) / frames.length;\n    const timer = setInterval(\n      () => setFrame((current) => (current + 1) % frames.length),\n      step,\n    );\n    return () => clearInterval(timer);\n  }, [cycle, frames.length, reduce]);\n\n  return (\n    <Text\n      allowFontScaling={false}\n      style={{\n        fontFamily: MONO,\n        fontSize: size,\n        lineHeight: size * 1.15,\n        color,\n        fontVariant: [\"tabular-nums\"],\n      }}\n    >\n      {frames[frame % frames.length]}\n    </Text>\n  );\n}\n\n// Each shape fully forms and holds before the next, so keyframes alternate\n// hold and change. A square turned 45° is the diamond; corner radius makes\n// the circle and squircle.\nconst MORPH_RADIUS = [0.5, 0.5, 0.1, 0.1, 0.1, 0.1, 0.3, 0.3, 0.5];\nconst MORPH_ROTATE = [0, 0, 90, 90, 225, 225, 270, 270, 360];\nconst MORPH_SCALE = [1, 1, 0.88, 0.88, 0.74, 0.74, 0.92, 0.92, 1];\n\nfunction Morph({ size, cycle, reduce, color }: PartProps) {\n  const side = size * 0.92;\n  const options = { duration: cycle * 4, enabled: !reduce };\n  const radius = useKeyframes(MORPH_RADIUS, options);\n  const rotate = useKeyframes(MORPH_ROTATE, options);\n  const scale = useKeyframes(MORPH_SCALE, options);\n  const pulse = useKeyframes(PULSE, {\n    duration: PULSE_DURATION,\n    enabled: reduce,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: pulse.value,\n    borderRadius: radius.value * side,\n    transform: [{ rotate: `${rotate.value}deg` }, { scale: scale.value }],\n  }));\n  return (\n    <View style={[styles.center, { width: size, height: size }]}>\n      <Animated.View\n        style={[\n          { width: side, height: side, backgroundColor: color },\n          animatedStyle,\n        ]}\n      />\n    </View>\n  );\n}\n\nconst COMET_TRAIL = [0, 1, 2, 3, 4, 5];\n\nfunction Comet({ size, cycle, reduce, color }: PartProps) {\n  const head = size * 0.2;\n  const radius = size / 2 - head / 2;\n  const turn = useKeyframes([0, 360], {\n    duration: cycle,\n    easing: Easing.linear,\n    enabled: !reduce,\n  });\n  const pulse = useKeyframes(PULSE, {\n    duration: PULSE_DURATION,\n    enabled: reduce,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: pulse.value,\n    transform: [{ rotate: `${turn.value}deg` }],\n  }));\n\n  return (\n    <Animated.View style={[{ width: size, height: size }, animatedStyle]}>\n      {COMET_TRAIL.map((index) => {\n        const dot = head * (1 - index * 0.13);\n        // Each trailing dot sits 15° further back along the orbit.\n        const angle = (-index * 15 * Math.PI) / 180;\n        return (\n          <Dot\n            key={index}\n            size={dot}\n            color={color}\n            style={{\n              position: \"absolute\",\n              left: size / 2 + Math.sin(angle) * radius - dot / 2,\n              top: size / 2 - Math.cos(angle) * radius - dot / 2,\n              opacity: 1 - index * 0.16,\n            }}\n          />\n        );\n      })}\n    </Animated.View>\n  );\n}\n\nconst SCRAMBLE_TARGET = \"LOADING\";\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@\";\n\nfunction Scramble({ size, cycle, reduce, color }: PartProps) {\n  const [text, setText] = useState(SCRAMBLE_TARGET);\n\n  useEffect(() => {\n    if (reduce) {\n      setText(SCRAMBLE_TARGET);\n      return;\n    }\n    let tick = 0;\n    const total = SCRAMBLE_TARGET.length + 4;\n    const timer = setInterval(\n      () => {\n        const reveal = tick % total;\n        setText(\n          Array.from(SCRAMBLE_TARGET, (char, index) =>\n            index < reveal\n              ? char\n              : SCRAMBLE_GLYPHS[\n                  Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)\n                ],\n          ).join(\"\"),\n        );\n        tick += 1;\n      },\n      (cycle / SCRAMBLE_TARGET.length) * 0.55,\n    );\n    return () => clearInterval(timer);\n  }, [cycle, reduce]);\n\n  const fontSize = size * 0.42;\n  return (\n    <Text\n      allowFontScaling={false}\n      style={{\n        fontFamily: MONO,\n        fontSize,\n        fontWeight: \"500\",\n        letterSpacing: fontSize * 0.2,\n        color,\n      }}\n    >\n      {text}\n    </Text>\n  );\n}\n\nfunction Metaballs({ size, cycle, reduce, color }: PartProps) {\n  const ball = size * 0.3;\n  const travel = size * 0.2;\n  const duration = cycle * 1.6;\n  const swap = useKeyframes([-1, 1, -1], { duration, enabled: !reduce });\n  // They meet in the middle twice per cycle; a blob swells there to join them.\n  const merge = useKeyframes([0, 1, 0, 1, 0], { duration, enabled: !reduce });\n  const pulse = useKeyframes([0.4, 1, 0.4], { duration, enabled: reduce });\n\n  const left = useAnimatedStyle(() => ({\n    opacity: reduce ? pulse.value : 1,\n    transform: [{ translateX: reduce ? -travel * 0.5 : swap.value * travel }],\n  }));\n  const right = useAnimatedStyle(() => ({\n    opacity: reduce ? pulse.value : 1,\n    transform: [{ translateX: reduce ? travel * 0.5 : -swap.value * travel }],\n  }));\n  const blob = useAnimatedStyle(() => ({\n    opacity: reduce ? 0 : 1,\n    transform: [{ scaleX: 1 + merge.value * 0.6 }, { scaleY: merge.value }],\n  }));\n\n  return (\n    <View style={[styles.center, { width: size, height: size }]}>\n      <Dot size={ball} color={color} style={[styles.layer, left]} />\n      <Dot size={ball} color={color} style={[styles.layer, right]} />\n      <Dot size={ball * 0.8} color={color} style={[styles.layer, blob]} />\n    </View>\n  );\n}\n\nfunction NewtonBall({\n  index,\n  size,\n  cycle,\n  reduce,\n  color,\n}: PartProps & { index: number }) {\n  const ball = size * 0.2;\n  const out = ball * 1.1;\n  // Only the end balls move: the left slides out and back, then the right,\n  // so the impact seems to pass through the three still balls between them.\n  const path =\n    index === 0\n      ? { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] }\n      : index === 4\n        ? { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] }\n        : null;\n  const x = useKeyframes(path?.x ?? [0], {\n    duration: cycle * 1.5,\n    times: path?.times,\n    enabled: !reduce && path !== null,\n  });\n  const animatedStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: x.value }],\n  }));\n  return <Dot size={ball} color={color} style={animatedStyle} />;\n}\n\nfunction Newton(props: PartProps) {\n  return (\n    <View style={[styles.row, { height: props.size * 0.2 }]}>\n      {[0, 1, 2, 3, 4].map((index) => (\n        <NewtonBall key={index} index={index} {...props} />\n      ))}\n    </View>\n  );\n}\n\nfunction HelixDot({\n  top,\n  delay,\n  front,\n  size,\n  cycle,\n  reduce,\n  color,\n}: PartProps & { top: number; delay: number; front: boolean }) {\n  const dot = size * 0.14;\n  const amp = size * 0.32;\n  const options = { duration: cycle, delay, enabled: !reduce };\n  const x = useKeyframes(front ? [amp, -amp, amp] : [-amp, amp, -amp], options);\n  const scale = useKeyframes(front ? [1, 0.5, 1] : [0.5, 1, 0.5], options);\n  const opacity = useKeyframes(\n    reduce ? [0.4, 1, 0.4] : front ? [1, 0.45, 1] : [0.45, 1, 0.45],\n    { duration: cycle, delay },\n  );\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ translateX: x.value }, { scale: scale.value }],\n  }));\n  return (\n    <Dot\n      size={dot}\n      color={color}\n      style={[\n        { position: \"absolute\", left: size / 2 - dot / 2, top },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nfunction Helix(props: PartProps) {\n  const rows = 7;\n  const dot = props.size * 0.14;\n  return (\n    <View style={{ width: props.size, height: props.size }}>\n      {Array.from({ length: rows }, (_, row) => {\n        const top = (row / (rows - 1)) * (props.size - dot);\n        const delay = (row / rows) * props.cycle;\n        return [true, false].map((front) => (\n          <HelixDot\n            key={`${row}-${front}`}\n            top={top}\n            delay={delay}\n            front={front}\n            {...props}\n          />\n        ));\n      })}\n    </View>\n  );\n}\n\nfunction Percent({ size, cycle, reduce, color }: PartProps) {\n  const [percent, setPercent] = useState(0);\n\n  useEffect(() => {\n    const total = reduce ? cycle * 2 : cycle;\n    const step = 40;\n    let elapsed = 0;\n    const timer = setInterval(() => {\n      elapsed += step;\n      const next = Math.min(100, Math.round((elapsed / total) * 100));\n      setPercent(next);\n      if (next >= 100) elapsed = 0;\n    }, step);\n    return () => clearInterval(timer);\n  }, [cycle, reduce]);\n\n  const track = Math.max(3, size * 0.1);\n  return (\n    <View style={[styles.column, { gap: size * 0.14, width: size * 1.4 }]}>\n      <Text\n        allowFontScaling={false}\n        style={{\n          fontFamily: MONO,\n          fontSize: size * 0.42,\n          lineHeight: size * 0.42,\n          fontWeight: \"500\",\n          fontVariant: [\"tabular-nums\"],\n          color,\n        }}\n      >\n        {percent}%\n      </Text>\n      <View style={[styles.track, { height: track, borderRadius: track / 2 }]}>\n        <View\n          style={[styles.overlay, { backgroundColor: color, opacity: 0.15 }]}\n        />\n        <View\n          style={{\n            width: `${percent}%`,\n            height: track,\n            borderRadius: track / 2,\n            backgroundColor: color,\n          }}\n        />\n      </View>\n    </View>\n  );\n}\n\n/** Seventeen loading indicators that all scale from one `size`. */\nexport function Loader({\n  variant = \"spinner\",\n  size = 32,\n  speed = 1,\n  label = \"Loading\",\n  color: colorProp,\n  className = \"\",\n  disableAnimation = false,\n  accessibilityLabel,\n  ...props\n}: LoaderProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const { color: custom } = useResolveClassNames(className);\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const color = colorProp ?? custom ?? foreground ?? \"black\";\n  const part: PartProps = {\n    size,\n    cycle: speed * 1000,\n    reduce: reducedMotion || disableAnimation,\n    color,\n  };\n  const frames = ASCII_SETS[variant];\n\n  return (\n    <View\n      {...props}\n      accessible\n      accessibilityRole=\"progressbar\"\n      accessibilityLabel={accessibilityLabel ?? label}\n      accessibilityState={{ busy: true }}\n      style={[styles.root, props.style]}\n    >\n      {variant === \"spinner\" && <Spinner {...part} />}\n      {variant === \"dots\" && <Dots {...part} />}\n      {variant === \"bars\" && <Bars {...part} />}\n      {variant === \"dot-matrix\" && <DotMatrix {...part} />}\n      {variant === \"dither\" && <Dither {...part} />}\n      {frames && <Ascii key={variant} frames={frames} {...part} />}\n      {variant === \"morph\" && <Morph {...part} />}\n      {variant === \"comet\" && <Comet {...part} />}\n      {variant === \"scramble\" && <Scramble {...part} />}\n      {variant === \"metaballs\" && <Metaballs {...part} />}\n      {variant === \"newton\" && <Newton {...part} />}\n      {variant === \"helix\" && <Helix {...part} />}\n      {variant === \"percent\" && <Percent {...part} />}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  root: {\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    alignSelf: \"flex-start\",\n  },\n  row: { flexDirection: \"row\", alignItems: \"center\" },\n  column: { alignItems: \"center\" },\n  grid: { flexDirection: \"row\", flexWrap: \"wrap\" },\n  center: { alignItems: \"center\", justifyContent: \"center\" },\n  overlay: { ...StyleSheet.absoluteFill },\n  layer: { position: \"absolute\" },\n  track: { width: \"100%\", overflow: \"hidden\" },\n});\n","type":"component"},{"path":"src/components/loader/use-keyframes.ts","content":"import { useEffect } from \"react\";\nimport {\n  cancelAnimation,\n  type EasingFunction,\n  type EasingFunctionFactory,\n  ReduceMotion,\n  useSharedValue,\n  withDelay,\n  withRepeat,\n  withSequence,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { EASE_IN_OUT } from \"../../styles/motion\";\n\ntype KeyframeOptions = {\n  /** One full pass through the keyframes, in milliseconds. */\n  duration: number;\n  /** Delay before the first pass only, like Motion's `delay` with `repeat`. */\n  delay?: number;\n  /** Offsets from 0 to 1 for each keyframe. Defaults to even spacing. */\n  times?: number[];\n  easing?: EasingFunction | EasingFunctionFactory;\n  /** When false, the value rests on its first keyframe. */\n  enabled?: boolean;\n};\n\n/**\n * Loops a shared value through `values` forever, like a Motion keyframe array\n * with `repeat: Infinity`. Loaders handle reduced motion themselves, so the\n * system preference never cuts these short.\n */\nexport function useKeyframes(\n  values: number[],\n  {\n    duration,\n    delay = 0,\n    times,\n    easing = EASE_IN_OUT,\n    enabled = true,\n  }: KeyframeOptions,\n) {\n  const first = values[0] ?? 0;\n  const value = useSharedValue(first);\n  // Arrays are rebuilt every render, so depend on their contents.\n  const valuesKey = values.join();\n  const timesKey = times?.join() ?? \"\";\n\n  // values and times are tracked by their keys.\n  useEffect(() => {\n    if (!enabled || values.length < 2) {\n      cancelAnimation(value);\n      value.value = first;\n      return;\n    }\n    const offsets =\n      times ?? values.map((_, index) => index / (values.length - 1));\n    const segments = values.slice(1).map((target, index) =>\n      withTiming(target, {\n        duration:\n          ((offsets[index + 1] ?? 1) - (offsets[index] ?? 0)) * duration,\n        easing,\n        reduceMotion: ReduceMotion.Never,\n      }),\n    );\n    value.value = first;\n    value.value = withDelay(\n      delay,\n      withRepeat(withSequence(...segments), -1),\n      ReduceMotion.Never,\n    );\n    return () => cancelAnimation(value);\n  }, [delay, duration, easing, enabled, first, timesKey, value, valuesKey]);\n\n  return value;\n}\n","type":"component"},{"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/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"}]}