{"_id":"expo-thinking-orbs","_rev":"3-487915ef6c3482ba4ac2d3818fef1689","name":"expo-thinking-orbs","dist-tags":{"latest":"0.2.1"},"versions":{"0.1.0":{"name":"expo-thinking-orbs","version":"0.1.0","keywords":["react-native","expo","skia","reanimated","loader","loading","spinner","animation","ai","agent","llm","thinking","orb","dots"],"author":{"url":"https://motionary.dev","name":"Mehdi Davoodi","email":"mahdidavoodi7@gmail.com"},"license":"MIT","_id":"expo-thinking-orbs@0.1.0","maintainers":[{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"}],"homepage":"https://github.com/mahdidavoodi7/expo-thinking-orbs#readme","bugs":{"url":"https://github.com/mahdidavoodi7/expo-thinking-orbs/issues"},"dist":{"shasum":"36396821de466a8a21f1036528ea793e057e57af","tarball":"https://registry.npmjs.org/expo-thinking-orbs/-/expo-thinking-orbs-0.1.0.tgz","fileCount":160,"integrity":"sha512-zTe1FBKEp8p8oDUjbKwozMKihMMVz16LVf0oi+WaQ55U5+2gf47P57GmTuP+jpsFBbiUzb+qu5aWUHfNVrbpvA==","signatures":[{"sig":"MEUCIGcoodihqgjcqSLV8ZR1NdmP02SCf39dSOUzyCuor86hAiEAqgDxBcXwHWwUd1r1URTskl4hFlZc6AouVz2Yl+dDt1I=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":328094},"main":"./lib/commonjs/index.js","types":"./lib/typescript/commonjs/src/index.d.ts","module":"./lib/module/index.js","exports":{".":{"import":{"types":"./lib/typescript/module/src/index.d.ts","default":"./lib/module/index.js"},"default":"./lib/module/index.js","require":{"types":"./lib/typescript/commonjs/src/index.d.ts","default":"./lib/commonjs/index.js"},"expo-thinking-orbs-source":"./src/index.tsx"},"./package.json":"./package.json"},"gitHead":"a01e25c9f7ecaa103c2b8630c05e3f6d45b2b81f","scripts":{"lint":"eslint \"**/*.{js,ts,tsx}\"","clean":"del-cli lib","example":"yarn workspace expo-thinking-orbs-example","prepare":"bob build","typecheck":"tsc"},"_npmUser":{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"},"prettier":{"useTabs":false,"tabWidth":2,"quoteProps":"consistent","singleQuote":true,"trailingComma":"es5"},"repository":{"url":"git+https://github.com/mahdidavoodi7/expo-thinking-orbs.git","type":"git"},"workspaces":["example"],"_npmVersion":"11.17.0","description":"Dotted thinking-orb loading animations for AI & agent UIs — six tuned states, rendered on the UI thread with Skia + Reanimated, for React Native & Expo","directories":{},"sideEffects":false,"_nodeVersion":"26.4.0","publishConfig":{"registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"packageManager":"yarn@4.11.0","devDependencies":{"react":"19.2.0","turbo":"^2.9.16","eslint":"^9.39.4","del-cli":"^7.0.0","prettier":"^3.8.3","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.0","react-native":"0.85.3","@eslint/compat":"^2.1.0","@eslint/eslintrc":"^3.3.5","eslint-plugin-ft-flow":"^3.0.11","react-native-worklets":"0.8.3","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.5.6","react-native-reanimated":"4.3.1","react-native-builder-bob":"^0.41.0","@react-native/babel-preset":"0.85.0","@shopify/react-native-skia":"2.6.2","@react-native/eslint-config":"0.85.0"},"peerDependencies":{"react":">=19","react-native":">=0.79","react-native-worklets":">=0.7.0","react-native-reanimated":">=4.0.0","@shopify/react-native-skia":">=2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/expo-thinking-orbs_0.1.0_1784893475754_0.9562929664853843","host":"s3://npm-registry-packages-npm-production"},"react-native-builder-bob":{"output":"lib","source":"src","targets":[["commonjs",{"esm":true}],["module",{"esm":true}],["typescript",{"project":"tsconfig.build.json"}]]},"create-react-native-library":{"type":"library","tools":["eslint"],"version":"0.63.0","languages":"js"}},"0.2.0":{"name":"expo-thinking-orbs","version":"0.2.0","keywords":["react-native","expo","skia","reanimated","loader","loading","spinner","animation","ai","agent","llm","thinking","orb","dots"],"author":{"url":"https://motionary.dev","name":"Mehdi Davoodi","email":"mahdidavoodi7@gmail.com"},"license":"MIT","_id":"expo-thinking-orbs@0.2.0","maintainers":[{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"}],"homepage":"https://github.com/mahdidavoodi7/expo-thinking-orbs#readme","bugs":{"url":"https://github.com/mahdidavoodi7/expo-thinking-orbs/issues"},"dist":{"shasum":"0712308bd76913289c7aa0673e3b7173338eb78e","tarball":"https://registry.npmjs.org/expo-thinking-orbs/-/expo-thinking-orbs-0.2.0.tgz","fileCount":205,"integrity":"sha512-b7C4lKM28DP+o7LT4wkvGKZo0c9NrQJaotc3faKnd6mDS6sya+m2sc7f+Z9KQ4acHIx3jnH0xB1VmzZV4HEzUQ==","signatures":[{"sig":"MEUCIQDIQS4PyfgEKBdiQhJYt7P4v/86R931vf9FOMSZiC22EQIgStETNiGiXM/q4H5H0ku8j58ry3WZHSMTx0IAuaWMI2k=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":665909},"main":"./lib/commonjs/index.js","types":"./lib/typescript/commonjs/src/index.d.ts","module":"./lib/module/index.js","exports":{".":{"import":{"types":"./lib/typescript/module/src/index.d.ts","default":"./lib/module/index.js"},"default":"./lib/module/index.js","require":{"types":"./lib/typescript/commonjs/src/index.d.ts","default":"./lib/commonjs/index.js"},"expo-thinking-orbs-source":"./src/index.tsx"},"./package.json":"./package.json"},"gitHead":"a03e83c38e6a20bb9b21f432f7d070ff3044bcfb","scripts":{"lint":"eslint \"**/*.{js,ts,tsx}\"","clean":"del-cli lib","example":"yarn workspace expo-thinking-orbs-example","prepare":"bob build","typecheck":"tsc"},"_npmUser":{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"},"prettier":{"useTabs":false,"tabWidth":2,"quoteProps":"consistent","singleQuote":true,"trailingComma":"es5"},"repository":{"url":"git+https://github.com/mahdidavoodi7/expo-thinking-orbs.git","type":"git"},"workspaces":["example"],"_npmVersion":"11.17.0","description":"Dotted thinking-orb loading animations for AI, agent & voice UIs — ten tuned states plus an amplitude-reactive voice orb, rendered on the UI thread with Skia + Reanimated, for React Native & Expo","directories":{},"sideEffects":false,"_nodeVersion":"26.4.0","publishConfig":{"registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"packageManager":"yarn@4.11.0","devDependencies":{"react":"19.2.3","turbo":"^2.9.16","eslint":"^9.39.4","del-cli":"^7.0.0","prettier":"^3.8.3","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.0","react-native":"0.86.0","@eslint/compat":"^2.1.0","@eslint/eslintrc":"^3.3.5","eslint-plugin-ft-flow":"^3.0.11","react-native-worklets":"0.10.0","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.5.6","react-native-reanimated":"4.5.0","react-native-builder-bob":"^0.41.0","@react-native/babel-preset":"0.86.0","@shopify/react-native-skia":"2.6.2","@react-native/eslint-config":"0.86.0"},"peerDependencies":{"react":">=19","react-native":">=0.79","react-native-worklets":">=0.7.0","react-native-reanimated":">=4.0.0","@shopify/react-native-skia":">=2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/expo-thinking-orbs_0.2.0_1785308091923_0.44627521634247413","host":"s3://npm-registry-packages-npm-production"},"react-native-builder-bob":{"output":"lib","source":"src","targets":[["commonjs",{"esm":true}],["module",{"esm":true}],["typescript",{"project":"tsconfig.build.json"}]]},"create-react-native-library":{"type":"library","tools":["eslint"],"version":"0.63.0","languages":"js"}},"0.2.1":{"name":"expo-thinking-orbs","version":"0.2.1","description":"AI thinking indicator and voice agent orb for React Native and Expo. Six dotted loading animations plus an audio-reactive voice orb, drawn on the UI thread with Skia and Reanimated.","main":"./lib/commonjs/index.js","module":"./lib/module/index.js","types":"./lib/typescript/commonjs/src/index.d.ts","exports":{".":{"expo-thinking-orbs-source":"./src/index.tsx","import":{"types":"./lib/typescript/module/src/index.d.ts","default":"./lib/module/index.js"},"require":{"types":"./lib/typescript/commonjs/src/index.d.ts","default":"./lib/commonjs/index.js"},"default":"./lib/module/index.js"},"./package.json":"./package.json"},"sideEffects":false,"scripts":{"example":"yarn workspace expo-thinking-orbs-example","clean":"del-cli lib","prepare":"bob build","typecheck":"tsc","lint":"eslint \"**/*.{js,ts,tsx}\""},"keywords":["ai-loading","thinking-indicator","loading-indicator","activity-indicator","voice-agent","voice-orb","ai-agent","livekit","react-native","expo","react-native-skia","skia","reanimated","react-native-animation","react-native-component","ui-components","loader","loading","spinner","animation","ai","agent","llm","thinking","orb","dots","ios","android"],"repository":{"type":"git","url":"git+https://github.com/mahdidavoodi7/expo-thinking-orbs.git"},"author":{"name":"Mehdi Davoodi","email":"mahdidavoodi7@gmail.com","url":"https://motionary.dev"},"license":"MIT","bugs":{"url":"https://github.com/mahdidavoodi7/expo-thinking-orbs/issues"},"homepage":"https://motionary.dev/blog/react-native-ai-loading-animation","publishConfig":{"registry":"https://registry.npmjs.org/"},"devDependencies":{"@eslint/compat":"^2.1.0","@eslint/eslintrc":"^3.3.5","@eslint/js":"^10.0.1","@react-native/babel-preset":"0.86.0","@react-native/eslint-config":"0.86.0","@shopify/react-native-skia":"2.6.2","@types/react":"^19.2.0","del-cli":"^7.0.0","eslint":"^9.39.4","eslint-config-prettier":"^10.1.8","eslint-plugin-ft-flow":"^3.0.11","eslint-plugin-prettier":"^5.5.6","prettier":"^3.8.3","react":"19.2.3","react-native":"0.86.0","react-native-builder-bob":"^0.41.0","react-native-reanimated":"4.5.0","react-native-worklets":"0.10.0","turbo":"^2.9.16","typescript":"^6.0.3"},"peerDependencies":{"@shopify/react-native-skia":">=2.0.0","react":">=19","react-native":">=0.79","react-native-reanimated":">=4.0.0","react-native-worklets":">=0.7.0"},"workspaces":["example"],"packageManager":"yarn@4.11.0","react-native-builder-bob":{"source":"src","output":"lib","targets":[["commonjs",{"esm":true}],["module",{"esm":true}],["typescript",{"project":"tsconfig.build.json"}]]},"prettier":{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false},"create-react-native-library":{"type":"library","languages":"js","tools":["eslint"],"version":"0.63.0"},"gitHead":"6a78e992a3d9d53e1163367b12a409eb482008e3","_id":"expo-thinking-orbs@0.2.1","_nodeVersion":"26.4.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-PMXzNuyWGJX9JLguzq5vO0tJ9apPtHybDc9Hsi5e75YfaOriWrpePvkYqgTicYF5WHRGrS5BTwgKyjsU4WliOw==","shasum":"9559be6d5c376e1344a6a180465f9ed9f90bc314","tarball":"https://registry.npmjs.org/expo-thinking-orbs/-/expo-thinking-orbs-0.2.1.tgz","fileCount":209,"unpackedSize":822629,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCi7HDwcYsYADlasoj2Z0j62oegEh5P3DNihHZixinwFwIhANqtTAh7B1RQHOGbHT6PqkswQZTq18mvWCR1+5clNBV7"}]},"_npmUser":{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"},"directories":{},"maintainers":[{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/expo-thinking-orbs_0.2.1_1786871495204_0.6467268829572121"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-24T11:44:35.687Z","modified":"2026-08-16T09:11:35.478Z","0.1.0":"2026-07-24T11:44:35.893Z","0.2.0":"2026-07-29T06:54:52.076Z","0.2.1":"2026-08-16T09:11:35.328Z"},"bugs":{"url":"https://github.com/mahdidavoodi7/expo-thinking-orbs/issues"},"author":{"name":"Mehdi Davoodi","email":"mahdidavoodi7@gmail.com","url":"https://motionary.dev"},"license":"MIT","homepage":"https://motionary.dev/blog/react-native-ai-loading-animation","keywords":["ai-loading","thinking-indicator","loading-indicator","activity-indicator","voice-agent","voice-orb","ai-agent","livekit","react-native","expo","react-native-skia","skia","reanimated","react-native-animation","react-native-component","ui-components","loader","loading","spinner","animation","ai","agent","llm","thinking","orb","dots","ios","android"],"repository":{"type":"git","url":"git+https://github.com/mahdidavoodi7/expo-thinking-orbs.git"},"description":"AI thinking indicator and voice agent orb for React Native and Expo. Six dotted loading animations plus an audio-reactive voice orb, drawn on the UI thread with Skia and Reanimated.","maintainers":[{"name":"mahdidavoodi7","email":"mahdidavoodi7@gmail.com"}],"readme":"# ✨ expo-thinking-orbs\n\n**AI thinking indicators and a voice‑agent orb for React Native and Expo.**\n\n[![npm](https://img.shields.io/npm/v/expo-thinking-orbs.svg)](https://www.npmjs.com/package/expo-thinking-orbs)\n[![npm downloads](https://img.shields.io/npm/dm/expo-thinking-orbs.svg)](https://www.npmjs.com/package/expo-thinking-orbs)\n[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n![platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20Android-lightgrey.svg)\n[![guide](https://img.shields.io/badge/guide-motionary.dev-FEEB00.svg)](https://motionary.dev/blog/react-native-ai-loading-animation)\n\n`expo-thinking-orbs` gives an AI or agent UI something better than a spinner: a\ndotted orb that says *which kind of work is happening*. Six hand‑tuned\nanimations cover thinking, searching, reasoning, listening, writing and\nforming, and a separate `<VoiceOrb>` covers a voice agent's whole session\nlifecycle while swelling with the actual audio. Everything is drawn entirely on\nthe UI thread with\n[React Native Skia](https://shopify.github.io/react-native-skia/) and\n[Reanimated](https://docs.swmansion.com/react-native-reanimated/), so the\nanimation holds its frame rate while your app streams tokens.\n\nThere is a longer write‑up of the approach in the\n[React Native AI loading animation guide](https://motionary.dev/blog/react-native-ai-loading-animation).\n\n## 🙏 Credit\n>\n> This is a React Native port of **[thinking-orbs](https://github.com/Jakubantalik/thinking-orbs)**\n> by **[Jakub Antalik](https://github.com/Jakubantalik)** — see the original\n> web library and its live demo at **[orbs.jakubantalik.com](https://orbs.jakubantalik.com)**.\n> All of the animation design and the per‑frame engine math are his; this package\n> re‑implements that engine on the UI thread for React Native. Original\n> library MIT © Jakub Antalik.\n\n## 🎬 Preview\n\nhttps://github.com/user-attachments/assets/f269ab22-ffab-4e1c-a525-c811e5236a9c\n\n<!--\n  GitHub inline video player: edit this file on github.com, drag\n  docs/preview.mp4 into the editor, then replace the image below with the\n  generated https://github.com/user-attachments/assets/… URL on its own\n  line (keep the GIF too — npm renders no video, only images).\n![expo-thinking-orbs — gallery of shimmering orb pills and the playground, on iOS and Android](docs/demo.gif)\n-->\n\n\n\n| state | verb | animation |\n| --- | --- | --- |\n| 🪐 `working` | thinking | particles on tilted orbits |\n| 🌐 `searching` | looking | a scan meridian sweeps a dotted globe |\n| 🧩 `solving` | reasoning | bands scramble in quarter turns, then click back solved |\n| 🎧 `listening` | hearing | a waveform rolls through latitude rings |\n| 🎼 `composing` | writing | an undulating multi‑band sash |\n| 🔷 `shaping` | forming | a dotted outline morphs circle → triangle → square |\n\nBuilding a **voice agent**? There is a seventh animation for that — a dot\nshell with eight behaviours, on its own component. See\n[Voice agents](#-voice-agents).\n\n## Contents\n\n- [Installation](#-installation)\n- [Quick start](#-quick-start)\n- [States & sizes](#-states--sizes)\n- [Theme & color](#-theme--color)\n- [Props](#-props)\n- [Voice agents](#-voice-agents)\n- [Many orbs? Share one canvas](#-many-orbs-share-one-canvas)\n- [How it works](#-how-it-works)\n- [Accessibility](#-accessibility)\n- [FAQ](#-faq)\n\n## 📦 Installation\n\nThe library ships JavaScript only; the heavy lifting is done by three peer\ndependencies. Install them with `expo install` so you get versions matched to\nyour Expo SDK:\n\n```sh\nnpx expo install expo-thinking-orbs @shopify/react-native-skia react-native-reanimated react-native-worklets\n```\n\nIn a bare React Native project, install the same packages with your package\nmanager and follow the Skia / Reanimated setup guides (Reanimated needs its\nBabel plugin — `babel-preset-expo` adds it automatically on Expo).\n\n<details>\n<summary><b>Peer dependencies</b></summary>\n\n| package | version |\n| --- | --- |\n| `react` | >= 19 |\n| `react-native` | >= 0.79 |\n| `@shopify/react-native-skia` | >= 2.0.0 |\n| `react-native-reanimated` | >= 4.0.0 |\n| `react-native-worklets` | >= 0.7.0 |\n\n</details>\n\n> **Note:** Reanimated 4 requires the **New Architecture** — the default since\n> React Native 0.76 / Expo SDK 52. Old‑architecture apps can't use this\n> library until they migrate.\n\n### 120 Hz on ProMotion\n\niOS caps `CADisplayLink` — which drives the orb's frame callback — at **60 fps**\nunless your app opts in, so on an iPhone Pro the animation runs at half the\nrefresh rate the display is capable of. This is an app‑level setting; the\nlibrary can't enable it for you.\n\n```json\n// app.json\n{ \"expo\": { \"ios\": { \"infoPlist\": { \"CADisableMinimumFrameDuration\": true } } } }\n```\n\nBare React Native apps set the same `CADisableMinimumFrameDuration` key to\n`true` in `Info.plist` directly. Android has no equivalent opt‑in — high\nrefresh rate is negotiated by the system.\n\nOpting in doubles the orb's per‑frame budget pressure: the same work now has\n**8.3 ms** per frame instead of 16.7 ms. Prefer one shared `<Canvas>` (see\n[Many orbs?](#-many-orbs-share-one-canvas)) if you render several at once.\n\n## 🚀 Quick start\n\n```tsx\nimport { ThinkingOrb } from 'expo-thinking-orbs';\n\nexport function Status() {\n  return <ThinkingOrb state=\"searching\" size={64} />;\n}\n```\n\nThat's it — the orb animates on the UI thread and follows the OS light/dark\nappearance automatically. Every orb shares one clock, so several mounted at\ndifferent times stay in mutual phase. 🕰️\n\n## 🎭 States & sizes\n\n```tsx\n<ThinkingOrb state=\"working\" />    {/* particles on tilted orbits */}\n<ThinkingOrb state=\"searching\" />  {/* a scan meridian sweeps a dotted globe */}\n<ThinkingOrb state=\"solving\" />    {/* bands scramble, then click back solved */}\n<ThinkingOrb state=\"listening\" />  {/* a waveform rolls through the rings */}\n<ThinkingOrb state=\"composing\" />  {/* an undulating multi-band sash */}\n<ThinkingOrb state=\"shaping\" />    {/* dotted outline: circle → triangle → square */}\n```\n\n`size` is any number. Two tunings ship — a dense **64‑point** design and a\nchunky **20‑point** design — and the component auto‑picks the nearer one\n(cutoff 36), then scales it vectorially to the exact size you pass:\n\n- `size={64}` → chat‑avatar scale\n- `size={20}` → inline‑with‑text scale\n- anything in between or beyond just works\n\n```tsx\n<ThinkingOrb state=\"working\" size={64} />\n<ThinkingOrb state=\"working\" size={20} />\n<ThinkingOrb state=\"working\" size={120} />\n```\n\n## 🎨 Theme & color\n\nBy default the orbs are strictly monochrome — dark ink on light backgrounds,\nlight ink on dark backgrounds — matching the original exactly. The palette is\npicked from the OS appearance and can be pinned:\n\n```tsx\n<ThinkingOrb theme=\"auto\" />   {/* default — follows useColorScheme() */}\n<ThinkingOrb theme=\"dark\" />   {/* pin: light dots, for dark backgrounds */}\n<ThinkingOrb theme=\"light\" />  {/* pin: dark dots, for light backgrounds */}\n```\n\nAn optional `color` tints the dots. The monochrome depth ramp is rebuilt from\nyour hue toward the theme extreme, so depth shading is preserved:\n\n```tsx\n<ThinkingOrb state=\"composing\" color=\"#3b82f6\" />\n```\n\nOmit `color` for the faithful grayscale original. 🖤🤍\n\n## ⚙️ Props\n\n| prop | type | default | description |\n| --- | --- | --- | --- |\n| `state` | `OrbState` | `'working'` | Which animation to show. |\n| `size` | `number` | `64` | Rendered size in points; any number. |\n| `dotScale` | `SharedValue<number> \\| number` | `1` | Weight of the dots: a multiplier on each dot's radius, positions untouched. `size` scales radii sub-linearly (`(size/300) ** 0.6`) so a large orb does not close up — raise this when a big orb's mark reads too fine. Animatable per frame from a `SharedValue`. |\n| `theme` | `'auto' \\| 'dark' \\| 'light'` | `'auto'` | Palette; `auto` follows the OS appearance. |\n| `speed` | `number` | `1` | Multiplier on the preset's baked speed. |\n| `paused` | `boolean` | `false` | Freeze on the current frame (continues from the same pose on resume). |\n| `color` | `string` | — | Optional tint; any RN color string. |\n| `colorTo` | `string` | — | A second ink endpoint. Supplying it turns `color` into a gradient the dots move along, and is what enables the colour animation below. |\n| `colorShift` | `SharedValue<number> \\| number` | — | Where the cloud sits between `color` (0) and `colorTo` (1). Omit it and the orb drifts it from its own clock; pass a `SharedValue` to drive it from a gesture or scroll at frame rate. |\n| `colorSpread` | `number` | `0.6` | How far a dot's own depth offsets its blend, 0–1. At 0 the shell is one colour moving as a mass; higher fans near and far dots along the gradient. |\n| `colorCycleMs` | `number` | `9000` | Period of the built‑in colour drift. Ignored when `colorShift` is supplied. |\n| `bands` | `OrbBands` | — | Band‑split audio: `low` swells the shell, `mid` drives a travelling ripple, `high` darkens the ink. The one audio input the six ported animations respond to. |\n| `tilt` | `OrbTilt` | — | Rotate the orb as a globe (`yaw`/`pitch`/`roll` in radians, or a unit quaternion `orientation`). Enters the projection, so the far side genuinely turns into sight. |\n| `style` | `StyleProp<ViewStyle>` | — | Container style (size drives width/height). |\n| `accessibilityLabel` | `string` | per‑state (e.g. `\"Working…\"`) | Overrides the default label. |\n| `debugFrameMs` | `SharedValue<number>` | — | Instrumentation: the worklet writes each frame's build+record time here. |\n\n`OrbState` is `'working' | 'searching' | 'solving' | 'listening' | 'composing' | 'shaping'`.\n\n### Colour, audio and rotation\n\nThree of those deserve a line of their own, because they turn the orb from a\nfixed asset into something your app can drive.\n\n`colorTo` opens up gradient ink. With only `color` set the painter takes the\noriginal single‑ramp path verbatim; add a second endpoint and the dots move\nalong a gradient, either on the orb's own slow clock or on a value you own:\n\n```tsx\n// Drifts between the two hues on its own, slowly.\n<ThinkingOrb state=\"composing\" color=\"#3b82f6\" colorTo=\"#a855f7\" />\n\n// Or drive it yourself, per frame, with no React render.\n<ThinkingOrb color=\"#3b82f6\" colorTo=\"#a855f7\" colorShift={scrollProgress} />\n```\n\n`bands` is the audio input for the **six ported animations** — distinct from\n`<VoiceOrb>`'s amplitude, and useful when you want `working` or `composing` to\nfollow a microphone without becoming the voice shell. `useVoiceLevels()`\nreturns exactly this shape:\n\n```tsx\nconst levels = useVoiceLevels();\n<ThinkingOrb state=\"composing\" bands={levels} />;\n```\n\n`tilt` rotates the orb as a globe rather than skewing the finished picture. It\nenters the projection, so dots on the leading edge sweep out of sight and\nhidden ones come round — which is why a `rotateX` transform on the `View` does\nnot look the same. Pass `yaw`/`pitch`/`roll` for small independent nudges, or\nan `orientation` quaternion when the globe is a free object being turned from\nwherever it already is:\n\n```tsx\n// Device tilt, a drag, a scroll offset — it is just an angle.\n<ThinkingOrb state=\"searching\" tilt={{ yaw, pitch }} />\n```\n\n## 🎙️ Voice agents\n\n`<VoiceOrb>` is a wrapper that takes a voice agent's lifecycle state and its\ntwo audio levels, and does the routing for you. Its state union is LiveKit's\n[`AgentState`](https://docs.livekit.io/reference/agents-js/types/agents.voice.AgentState.html)\nverbatim, so a session state passes straight through with no mapping table:\n\n```tsx\nimport { VoiceOrb } from 'expo-thinking-orbs';\n\nfunction AgentAvatar() {\n  const { state } = useVoiceAssistant(); // '@livekit/components-react'\n  return (\n    <VoiceOrb\n      state={state}\n      inputAmplitude={micLevel}      // SharedValue<number>, 0–1\n      outputAmplitude={agentLevel}   // SharedValue<number>, 0–1\n      size={140}\n    />\n  );\n}\n```\n\nUsing another SDK? The union is nine plain strings — map yours onto them, or\nreach for `<ThinkingOrb>` and the four lifecycle states directly.\n\n**The eight behaviours**\n\nNine states map onto eight behaviours — `failed` reuses `disconnected`'s, but\nfrozen. All eight act on one shared dot shell — a latitude-ring lattice, the\nsame structure `wave` and `globe` use — at the same tempo and scale as the\nported animations. Because the dot set is shared, a state change **blends**:\nthe dots travel to their new behaviour over ~420 ms instead of cutting.\n\n| state | behaviour |\n| --- | --- |\n| `disconnected` | dim, drawn in, near-motionless; a faint ping crawls across and finds nothing |\n| `connecting` | fast spikes and hard shear, but faint — straining, not yet through |\n| `pre-connect-buffering` | a bright band sweeps pole to pole and back; fuller than `connecting` |\n| `failed` | `disconnected`'s shell, frozen on the current frame |\n| `initializing` | scattered dots assemble onto the shell in a rolling wave |\n| `idle` | the undulation at half tempo and a quarter depth — at rest, breathing |\n| `listening` | wavefronts **converge inward**, carrying dots toward the core with the mic |\n| `thinking` | `wave`'s undulation at a narrower swing — the calm middle of a turn |\n| `speaking` | wavefronts **expand outward**, carrying dots to the rim with the agent's voice |\n\nThese are staged so progress is legible without reading a label — each step\nalong `disconnected → connecting → buffering → initializing → idle` is\nmeasurably fuller and brighter than the last. `failed` freezes the shell;\n`disconnected` keeps running, because straining for a signal is the point of\nit.\n\n### Feeding it real audio\n\nThis package renders; it does not capture audio. `useVoiceAmplitude()` is the\nbridge — it owns a `SharedValue` the orb reads every frame, and converts the\nformats you are actually likely to have. Setting it never re-renders React.\n\n```tsx\nimport { VoiceOrb, useVoiceAmplitude } from 'expo-thinking-orbs';\n\nfunction AgentAvatar() {\n  const { state } = useVoiceAssistant();\n  const mic = useVoiceAmplitude();\n  const agent = useVoiceAmplitude();\n\n  return (\n    <VoiceOrb\n      state={state}\n      inputAmplitude={mic.level}\n      outputAmplitude={agent.level}\n      size={180}\n    />\n  );\n}\n```\n\nThen push levels in from whichever source you have:\n\n| your source | call |\n| --- | --- |\n| already `0`–`1` (LiveKit `useTrackVolume`, a VU meter) | `mic.set(v)` |\n| dBFS (`expo-audio` metering, `expo-av`, `AVAudioRecorder`) | `mic.setDb(db)` |\n| raw PCM frames in `-1..1` (a Gemini Live / Realtime stream) | `agent.setSamples(frames)` |\n\n`setDb` treats −45 dBFS as silence and 0 dB as full, on an ear-shaped curve —\nconversational speech (≈ −20 dB) lands around 0.66 and close talking (≈ −6 dB)\naround 0.90, so the orb's range is spent on speech rather than on room noise.\nBoth the floor and the curve are options if your source runs hotter or\nquieter. `setSamples` takes the RMS of the block.\n\nA stalled meter handing you `NaN` reads as silence rather than corrupting the\ngeometry.\n\n### How amplitude behaves\n\nAudio level scales how **deep** a gesture goes, never how **fast**. The tempo\nis fixed at the ported animations' pace — driving the rate from amplitude is\nfrequency modulation, and reads as vibration rather than as a voice. The\nwavefronts travel through screen-space radius, so every dot the same distance\nfrom the centre moves together and the shell stays a surface.\n\nLevels are clamped and smoothed on the UI thread with a fast attack (45 ms)\nand slow release (240 ms), so feed a raw meter — pre-smoothing on top will\nonly make the orb lag the voice.\n\nAmplitude is **ignored** when the OS reduce-motion setting is on, and frozen\nwhile `paused`. The six ported animations have no audio response by design;\n`amplitude` only reaches the voice shell.\n\n## 🤖 Many orbs? Share one canvas\n\nEvery `<ThinkingOrb>` mounts its own Skia `<Canvas>`, and each canvas is a\nseparate native surface — on Android each one is composited every frame, so\na screen full of small animating canvases drops UI frames on mid‑range\ndevices. For those screens, use the `useThinkingOrbPicture` hook and draw\nseveral orbs (plus any other animated Skia content) into **one** canvas:\n\n```tsx\nimport { Canvas, Group, Picture } from '@shopify/react-native-skia';\nimport { useThinkingOrbPicture } from 'expo-thinking-orbs';\n\nfunction StatusRow() {\n  const working = useThinkingOrbPicture({ state: 'working', size: 40 });\n  const searching = useThinkingOrbPicture({ state: 'searching', size: 40 });\n  return (\n    <Canvas style={{ width: 96, height: 40 }}>\n      <Picture picture={working} />\n      <Group transform={[{ translateX: 56 }]}>\n        <Picture picture={searching} />\n      </Group>\n    </Canvas>\n  );\n}\n```\n\nThe picture is recorded at `(0, 0, size, size)`; offset it with a\n`<Group transform>`. The example app's gallery draws each pill (orb +\nshimmering label) this way.\n\n## 🧠 How it works\n\nThe original thinking-orbs is **not** shader‑based: each state is pure CPU math\nthat emits a per‑frame array of a few dozen to a few hundred grayscale dots,\nz‑sorted and painted as circles. A full‑screen fragment shader looping over\nhundreds of dots per pixel would be *slower* on mobile GPUs, so this port keeps\nthe CPU‑math design and moves it to the UI thread:\n\n- ⚛️ **React renders once per prop change.** No per‑frame React work.\n- 🕰️ A `useFrameCallback` advances a `phase` shared value, seeded from the\n  shared frame clock (so instances lock in phase) and accumulated (so speed\n  changes and pause/resume never jump).\n- 🧵 A `useDerivedValue` **worklet** computes the mode's dot cloud at time `t`,\n  z‑sorts it, and records a Skia `Picture`. Dots live in reused\n  structure‑of‑arrays `Float32Array` buffers, ordering goes through a reused\n  index list, one `Paint` is shared across all orbs, and colors come from a\n  256‑entry LUT — a frame allocates essentially nothing but the picture, so\n  the UI thread runs **GC‑quiet** even with dozens of orbs mounted. 🗑️🚫\n- 🖼️ A `<Picture>` inside a fixed‑size `<Canvas>` draws it. Everything after\n  the first render happens on the UI thread; the JS thread stays free.\n\nTime‑independent setup (lattices, orbit bases, shape outlines, hash tables) is\nprecomputed once per resolved preset on the JS thread.\n\n## ♿ Accessibility\n\n- Each orb is an `accessibilityRole=\"image\"` with a sensible per‑state\n  `accessibilityLabel` (e.g. `\"Searching…\"`), overridable via the prop.\n- `prefers-reduced-motion` (via Reanimated's `useReducedMotion`) slows the orb\n  to a third of its pace rather than freezing it, and holds the voice level\n  constant so the shell stops tracking speech. Reduced motion asks for less\n  motion, not none — and a frozen orb loses the state distinction entirely,\n  since `idle`, `listening` and `thinking` share a resting radius by design\n  and it is the *motion* that tells them apart. Theme is still followed.\n- `paused` stops the clock completely if you do want a still orb, and the\n  voice `failed` state freezes on its own.\n\n## 📱 Running the example app\n\nThe `example/` app is an Expo SDK 57 project with three screens — a gallery of\nstates as shimmering status pills (both tuned designs), a playground with live\nstate/theme/color/size/speed/amplitude controls, and a voice screen that runs\n`<VoiceOrb>` through a full agent lifecycle against a synthesised speech\nenvelope.\n\n```sh\nyarn                       # install (from the repo root)\ncd example\nnpx expo run:ios           # or: npx expo run:android\n```\n\nBecause the library depends on Skia, Reanimated and Worklets (all native), the\nexample needs a **development build** (`expo run:*`) rather than Expo Go —\nthough with matched SDK versions Expo Go may work for a quick look. On Android,\nalso give the release variant a sanity check.\n\n## ❓ FAQ\n\n### How do you show an AI \"thinking\" indicator in React Native?\n\nRender `<ThinkingOrb state=\"working\" />` and switch `state` as your agent\nchanges what it is doing — `searching` while it hits a tool, `solving` while it\nreasons, `composing` while it streams a reply. The point of six animations\nrather than one spinner is that the shape tells the user *which* kind of work\nis happening, which is the thing a spinner cannot say. Every orb shares one\nclock, so several mounted at different times stay in mutual phase.\n\n### Does it work in Expo Go?\n\nNot reliably. React Native Skia, Reanimated and Worklets are all native, so the\nexample app expects a development build (`npx expo run:ios` /\n`npx expo run:android`). With exactly matched SDK versions Expo Go may work for\na quick look, but treat a dev build as the supported path.\n\n### Does it work with LiveKit?\n\nYes, with no mapping table. `VoiceOrbState` is LiveKit's\n[`AgentState`](https://docs.livekit.io/reference/agents-js/types/agents.voice.AgentState.html)\nunion verbatim, so the `state` from `useVoiceAssistant()` passes straight into\n`<VoiceOrb>`. Using another SDK is fine too — the union is nine plain strings,\nso map yours onto them.\n\n### How do I feed it real microphone audio?\n\n`useVoiceAmplitude()` owns a `SharedValue` the orb reads every frame, and\nsetting it never re-renders React. Call `set(v)` for values already in 0–1\n(LiveKit's `useTrackVolume`, a VU meter), `setDb(db)` for dBFS metering\n(`expo-audio`, `expo-av`, `AVAudioRecorder`), or `setSamples(frames)` for raw\nPCM in −1..1. Feed a raw meter: levels are already smoothed on the UI thread\nwith a 45 ms attack and 240 ms release, so pre-smoothing only adds lag.\n\n### Why does amplitude change the depth of the animation but not its speed?\n\nBecause driving the rate from amplitude is frequency modulation, and it reads\nas vibration rather than as a voice. Audio level scales how deep a gesture\ngoes; the tempo stays fixed at the ported animations' pace.\n\n### Is it expensive to render several orbs at once?\n\nEach `<ThinkingOrb>` mounts its own Skia `<Canvas>`, and every canvas is a\nseparate native surface that Android composites each frame, so a screen full of\nsmall orbs will drop frames on mid-range devices. Use\n[`useThinkingOrbPicture`](#-many-orbs-share-one-canvas) to draw several into\none shared canvas instead. A frame allocates essentially nothing — reused\n`Float32Array` buffers, one shared `Paint`, a 256-entry colour LUT — so the UI\nthread stays GC-quiet even with dozens mounted.\n\n### Why is the animation only running at 60fps on my iPhone Pro?\n\niOS caps `CADisplayLink` at 60fps unless the app opts in. Set\n`CADisableMinimumFrameDuration` to `true` in `Info.plist` (or via\n`expo.ios.infoPlist` in `app.json`). This is an app-level setting the library\ncannot enable for you — see [120 Hz on ProMotion](#120-hz-on-promotion).\n\n### What happens with reduce motion enabled?\n\nThe orb slows to a third of its pace rather than freezing, and the voice shell\nstops tracking speech. That is deliberate: `idle`, `listening` and `thinking`\nshare a resting radius by design, so a fully frozen orb would lose the state\ndistinction entirely. Reduced motion asks for less motion, not none. Use\n`paused` if you genuinely want a still orb.\n\n## More React Native components\n\nI build animated React Native and Expo components at\n**[motionary.dev](https://motionary.dev)** — this one is free and MIT, and the\nrest of the catalog is there.\n\n- [React Native component reference](https://motionary.dev/components) — free\n- [Browse the catalog](https://motionary.dev/animations)\n- [React Native AI loading animation guide](https://motionary.dev/blog/react-native-ai-loading-animation) — the longer write-up behind this package\n\n## 📄 License\n\nMIT. Original thinking-orbs © Jakub Antalik; React Native port ©\n[Mehdi Davoodi](https://motionary.dev). See [LICENSE](LICENSE).\n\n---\n\nMade with 🤍 by [Mehdi Davoodi](https://motionary.dev) — more of my projects\nlive at **[motionary.dev](https://motionary.dev)**.\n\nIf this saved you an afternoon, a ⭐ on the repo helps more people find it.\n","readmeFilename":"README.md"}