{"slug":"voice-orb","name":"Voice Orb","description":"A liquid sphere that moves with your voice.","group":"AI agents","detail_url":"https://beui.dev/native/r/voice-orb","page_url":"https://beui.dev/react-native/voice-orb","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/voice-orb?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/voice-orb","libraries":["@shopify/react-native-skia","react","react-native","react-native-reanimated"],"install":["npx expo install @shopify/react-native-skia 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/voice-orb/index.ts","content":"export type { VoiceOrbProps } from \"./voice-orb\";\nexport { VoiceOrb } from \"./voice-orb\";\n","type":"component"},{"path":"src/components/voice-orb/voice-orb.tsx","content":"import { Canvas, Fill, Shader, Skia } from \"@shopify/react-native-skia\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  PixelRatio,\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  isSharedValue,\n  type SharedValue,\n  useAnimatedReaction,\n  useAnimatedStyle,\n  useDerivedValue,\n  useFrameCallback,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { SPRING_GLIDE, SPRING_PRESS } from \"../../styles/motion\";\n\n// A breathing silhouette around an advected liquid field. Voice energy opens\n// the folds and ripples the rim; the grain stays fixed to the screen. Ported\n// from beUI's WebGL shader: Skia's origin is top-left, so y is flipped, and\n// the output is premultiplied.\nconst SOURCE = `\nuniform float2 u_resolution;\nuniform float u_scale;\nuniform float u_time;\nuniform float u_activity;\nuniform float3 u_base;\nuniform float3 u_light;\nuniform float3 u_dark;\n\nfloat hash(float3 p) {\n  p = fract(p * 0.1031);\n  p += dot(p, p.yzx + 33.33);\n  return fract((p.x + p.y) * p.z);\n}\nfloat noise(float3 p) {\n  float3 i = floor(p);\n  float3 f = fract(p);\n  f = f * f * (3.0 - 2.0 * f);\n  return mix(mix(mix(hash(i), hash(i + float3(1.0, 0.0, 0.0)), f.x),\n                 mix(hash(i + float3(0.0, 1.0, 0.0)), hash(i + float3(1.0, 1.0, 0.0)), f.x), f.y),\n             mix(mix(hash(i + float3(0.0, 0.0, 1.0)), hash(i + float3(1.0, 0.0, 1.0)), f.x),\n                 mix(hash(i + float3(0.0, 1.0, 1.0)), hash(i + float3(1.0, 1.0, 1.0)), f.x), f.y), f.z);\n}\nfloat fbm(float3 p) {\n  return noise(p) * 0.57 + noise(p * 2.03 + 7.2) * 0.28 + noise(p * 4.07 + 3.1) * 0.15;\n}\nhalf4 main(float2 fragCoord) {\n  float2 res = u_resolution * u_scale;\n  float2 fc = float2(fragCoord.x, u_resolution.y - fragCoord.y) * u_scale;\n  float2 p = (fc * 2.0 - res) / min(res.x, res.y);\n  float t = u_time * 0.38;\n  float angle = atan(p.y, p.x);\n  float breath = sin(u_time * 1.4) * 0.008;\n  float ripple = sin(angle * 3.0 + t * 2.1) * 0.55\n               + sin(angle * 5.0 - t * 1.7) * 0.30\n               + sin(angle * 2.0 + t) * 0.15;\n  p /= 0.91 + breath + u_activity * 0.025 + ripple * (0.004 + u_activity * 0.024);\n  float r2 = dot(p, p);\n  float aa = 3.0 / min(res.x, res.y);\n  float alpha = 1.0 - smoothstep(1.0 - aa, 1.0, r2);\n  if (alpha <= 0.0) { return half4(0.0); }\n  float3 normal = float3(p, sqrt(max(0.0, 1.0 - r2)));\n  float3 flow = float3(normal.xy * 1.65, normal.z * 1.4);\n  flow.xy += float2(sin(normal.y * 2.6 + t), cos(normal.x * 2.3 - t * 0.8)) * (0.25 + u_activity * 0.22);\n  float3 warp = float3(noise(flow + float3(0.0, t, t * 0.3)),\n                       noise(flow + float3(4.7, -t * 0.7, t)),\n                       noise(flow + float3(t * 0.5, 9.2, -t))) - 0.5;\n  float pigment = fbm(flow + warp * 1.4 + float3(t * 0.3, -t * 0.2, t * 0.4));\n  float fold = normal.x * 0.50 + normal.y * 0.32 + sin(normal.y * 2.6 - t) * 0.30 + (pigment - 0.5) * 0.65;\n  float shadow = smoothstep(-0.30, 0.42, fold);\n  float3 color = mix(u_dark, u_base, shadow);\n  color = mix(color, u_light, smoothstep(0.40, 0.82, pigment) * 0.34);\n  float diffuse = max(0.0, dot(normal, normalize(float3(-0.4, 0.65, 1.0))));\n  color *= 0.64 + 0.46 * diffuse;\n  float shine = exp(-pow((fold + 0.06) * 8.0, 2.0)) * pow(normal.z, 0.65);\n  float innerLight = exp(-pow((fold - 0.28) * 4.0, 2.0)) * normal.z;\n  color = mix(color, u_light, innerLight * (0.18 + u_activity * 0.16));\n  color = mix(color, mix(u_light, float3(1.0), 0.55), shine * 0.78);\n  float gleam = pow(max(0.0, dot(normal, normalize(float3(-0.45, 0.55, 1.0)))), 24.0);\n  color += gleam * 0.16;\n  float rim = pow(1.0 - normal.z, 3.0);\n  color = mix(color, u_light, rim * (0.36 + 0.25 * max(0.0, -normal.x)));\n  float grain = hash(float3(fc, 1.0)) - 0.5;\n  color += grain * 0.032;\n  color = clamp(color, 0.0, 1.0);\n  return half4(half3(color * alpha), half(alpha));\n}`;\n\nconst EFFECT = Skia.RuntimeEffect.Make(SOURCE);\nconst DEFAULT_COLORS = [\"#e8754d\", \"#ffe0ac\", \"#346b52\"] as const;\n// Grain and edge softness match the web at up to 2× density.\nconst SCALE = Math.min(PixelRatio.get(), 2);\n\nfunction rgb(hex: string): [number, number, number] {\n  const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);\n  if (!match?.[1])\n    throw new Error(\"VoiceOrb colors must be #RGB or #RRGGBB hex colors.\");\n  const value =\n    match[1].length === 3 ? match[1].replace(/./g, (c) => c + c) : match[1];\n  return [0, 2, 4].map(\n    (i) => Number.parseInt(value.slice(i, i + 2), 16) / 255,\n  ) as [number, number, number];\n}\n\nconst level = (value: number) => {\n  \"worklet\";\n  return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;\n};\n\nexport type VoiceOrbProps = Omit<ViewProps, \"children\"> & {\n  /**\n   * Voice level from 0 to 1. Pass a shared value to drive it from audio\n   * metering without re-rendering. Defaults to 0.\n   */\n  activity?: number | SharedValue<number>;\n  /** Base, highlight and shadow, as #RGB or #RRGGBB. */\n  colors?: readonly [string, string, string];\n  /** Pauses the surface in place. Defaults to true. */\n  active?: boolean;\n  /** Defaults to 1. */\n  speed?: number;\n  /** Makes the orb a button, such as to start or stop listening. It squishes under your finger. */\n  onPress?: PressableProps[\"onPress\"];\n  onLongPress?: PressableProps[\"onLongPress\"];\n  /** Size in points. Defaults to 256. */\n  size?: number;\n  disableAnimation?: boolean;\n};\n\n/**\n * A grainy liquid sphere whose surface and silhouette respond to voice. The\n * shader runs on the GPU and time only moves while it's active, so pausing\n * leaves the surface exactly where it was.\n */\nexport function VoiceOrb({\n  activity = 0,\n  colors = DEFAULT_COLORS,\n  active = true,\n  speed = 1,\n  onPress,\n  onLongPress,\n  size = 256,\n  disableAnimation = false,\n  accessibilityLabel,\n  style,\n  ...props\n}: VoiceOrbProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const animated = active && !reduce;\n  const target = useSharedValue(0);\n  const smooth = useSharedValue(0);\n  const elapsed = useSharedValue(0);\n  const pressed = useSharedValue(1);\n  const [base, light, dark] = useMemo(\n    () => [rgb(colors[0]), rgb(colors[1]), rgb(colors[2])] as const,\n    [colors],\n  );\n  const shared = isSharedValue(activity)\n    ? (activity as SharedValue<number>)\n    : null;\n  const plain = shared ? 0 : (activity as number);\n  const [error] = useState(EFFECT ? null : \"unavailable\");\n\n  // A number prop updates on render; a shared value is followed on the UI thread.\n  useEffect(() => {\n    if (!shared) target.value = animated ? level(plain) : 0;\n  }, [animated, plain, shared, target]);\n  useAnimatedReaction(\n    () => (shared ? shared.value : null),\n    (value) => {\n      if (value !== null) target.value = animated ? level(value) : 0;\n    },\n    [shared, animated],\n  );\n  useAnimatedReaction(\n    () => target.value,\n    (value) => {\n      smooth.value = animated ? withSpring(value, SPRING_GLIDE) : 0;\n    },\n    [animated],\n  );\n\n  useFrameCallback((frame) => {\n    const dt = Math.min(0.05, (frame.timeSincePreviousFrame ?? 16) / 1000);\n    elapsed.value += dt * Math.max(0, speed) * (1 + smooth.value * 0.6);\n  }, animated);\n\n  const uniforms = useDerivedValue(() => ({\n    u_resolution: [size, size],\n    u_scale: SCALE,\n    u_time: elapsed.value,\n    u_activity: smooth.value,\n    u_base: base,\n    u_light: light,\n    u_dark: dark,\n  }));\n\n  // Lifts, widens and squashes a touch at full voice; squishes when pressed.\n  const silhouette = useAnimatedStyle(() => {\n    const v = smooth.value;\n    return {\n      transform: [\n        { translateY: -v * 2 },\n        { scaleX: (1 + v * 0.04) * (2 - pressed.value) },\n        { scaleY: (1 - v * 0.018) * pressed.value },\n      ],\n    };\n  });\n\n  if (error || !EFFECT) {\n    return (\n      <View\n        style={[\n          { width: size, height: size, borderRadius: size / 2 },\n          styles.fallback,\n          style,\n        ]}\n        className=\"border border-border bg-surface p-8\"\n        {...props}\n      >\n        <Text className=\"text-center text-xs text-muted\">\n          Orb rendering unavailable\n        </Text>\n      </View>\n    );\n  }\n\n  const orb = (\n    <Animated.View style={[{ width: size, height: size }, silhouette]}>\n      <Canvas style={StyleSheet.absoluteFill}>\n        <Fill>\n          <Shader source={EFFECT} uniforms={uniforms} />\n        </Fill>\n      </Canvas>\n    </Animated.View>\n  );\n\n  return (\n    <View\n      accessible={Boolean(accessibilityLabel) && !onPress}\n      accessibilityRole={accessibilityLabel && !onPress ? \"image\" : undefined}\n      accessibilityLabel={onPress ? undefined : accessibilityLabel}\n      importantForAccessibility={\n        accessibilityLabel || onPress ? \"auto\" : \"no-hide-descendants\"\n      }\n      style={[{ width: size, height: size }, style]}\n      {...props}\n    >\n      {onPress || onLongPress ? (\n        <Pressable\n          accessibilityRole=\"button\"\n          accessibilityLabel={accessibilityLabel}\n          onPress={onPress}\n          onLongPress={onLongPress}\n          onPressIn={() => {\n            if (!reduce) pressed.value = withSpring(0.94, SPRING_PRESS);\n          }}\n          onPressOut={() => {\n            pressed.value = withSpring(1, SPRING_PRESS);\n          }}\n        >\n          {orb}\n        </Pressable>\n      ) : (\n        orb\n      )}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fallback: { alignItems: \"center\", justifyContent: \"center\" },\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  /* 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"}]}