Skip to content
All posts

9 min read

React Native AI loading animation: past the spinner

A spinner cannot say whether an agent is searching, reasoning or writing. Six states that can, plus a voice orb that follows real audio.

An AI loading state in React Native should say which kind of work is happening, not just that work is happening. A spinner has one shape and one message. An agent that is searching the web, reasoning through a plan, or streaming a reply is doing three visibly different things, and the loading indicator is the only place in the UI where the user can see the difference before the answer arrives.

There is a second, harder requirement hiding behind that one. An agent UI is busy precisely when it is loading: tokens are streaming, state is updating, React is re-rendering a message list many times a second. Any animation driven from JavaScript will stutter exactly when the user is watching it hardest. So the animation has to live on the UI thread, and it has to stay there. This post covers both halves, and the packaged version is expo-thinking-orbs, which is free and MIT.

What should an AI loading state actually show?

The useful unit is the verb. An agent turn is not one undifferentiated wait, it is a sequence of recognisably different activities, and the user's patience depends on knowing which one they are in. Waiting through a tool call feels different from waiting through a long generation, and a shape that distinguishes them buys you real perceived-latency headroom.

State Verb What the user is waiting on
working thinking General compute, no specific tool
searching looking A retrieval or web tool call
solving reasoning Multi-step planning, a chain of thought
listening hearing Speech input being taken in
composing writing A reply being generated or streamed
shaping forming Structured output, an artifact taking shape

You do not need exactly these six. You do need more than one, and you need the difference between them to be legible at a glance rather than through a text label, because the label is the thing users stop reading first.

How do you add an AI thinking indicator in React Native?

Install the package and switch the state prop as your agent changes activity. It ships JavaScript only, with three native peers doing the work.

npx expo install expo-thinking-orbs @shopify/react-native-skia react-native-reanimated react-native-worklets
import { ThinkingOrb } from 'expo-thinking-orbs';

type Phase = 'idle' | 'searching' | 'reasoning' | 'streaming';

export function AgentStatus({ phase }: { phase: Phase }) {
  if (phase === 'idle') return null;

  return (
    <ThinkingOrb
      state={
        phase === 'searching' ? 'searching'
        : phase === 'reasoning' ? 'solving'
        : 'composing'
      }
      size={24}
    />
  );
}
This needs a development build, not Expo Go. Skia, Reanimated and Worklets are all native modules, so run npx expo run:ios or npx expo run:android. Reanimated 4 also requires the New Architecture, the default since React Native 0.76 and Expo SDK 52.

One detail worth knowing: every orb shares a single clock, so two mounted at different moments stay in mutual phase rather than drifting into a visual mess when you show several in a list.

Why not write it as a shader?

This is the interesting engineering call, and the intuition points the wrong way. A dotted orb looks like an obvious fragment shader: hundreds of dots, clearly a GPU job.

It is not. A fragment shader runs once per pixel, and each of those invocations would have to loop over every dot to decide whether that pixel is inside one. At a few hundred dots and a few hundred thousand pixels, that is tens of millions of distance checks per frame. The CPU version does the opposite: it computes a few hundred dot positions, z-sorts them, and hands Skia a few hundred circles to rasterize. The work scales with the number of dots, not with the number of pixels times the number of dots.

So the animation stays CPU math. The trick is where that math runs.

How do you keep it off the JS thread while tokens stream?

This is the part that actually matters for an AI UI. If your indicator is a setState loop, or an Animated value without the native driver, it shares a thread with the token stream and the message list re-render, and it will stutter exactly during the wait it exists to make pleasant.

The shape that works: React renders once per prop change, a frame callback advances a phase value on the UI thread, and a worklet builds the frame's geometry and records it into a Skia Picture. Nothing crosses the bridge per frame.

// A frame callback advances phase on the UI thread. Accumulated rather than
// read off a wall clock, so pause and resume continue instead of jumping.
useFrameCallback((frame) => {
  'worklet';
  if (frame.timeSincePreviousFrame === null) return;
  phase.value += (frame.timeSincePreviousFrame / 1000) * speed;
});

// A worklet builds the dot cloud at time t, z-sorts it, records a Picture.
const picture = useDerivedValue(() => {
  'worklet';
  const dots = buildDots(preset, phase.value); // reused Float32Array buffers
  return recordPicture(dots, paint, lut);
});

The allocation discipline is what makes it hold up with several orbs mounted. Dot positions live in reused structure-of-arrays Float32Array buffers, z-ordering goes through a reused index list, one Paint is shared across every orb, and colours come from a 256-entry lookup table. A frame allocates essentially nothing but the picture itself, so the UI thread stays quiet rather than sawtoothing through garbage collections at the worst possible moment.

Everything time-independent, meaning lattices, orbit bases, shape outlines and hash tables, is precomputed once on the JS thread when the preset resolves.

One canvas, many orbs

Each orb mounting its own Skia Canvas is fine for one or two, and a problem for a screen full of them: every canvas is a separate native surface that Android composites each frame. When you need several, record them into one canvas instead.

import { Canvas, Group, Picture } from '@shopify/react-native-skia';
import { useThinkingOrbPicture } from 'expo-thinking-orbs';

function StatusRow() {
  const working = useThinkingOrbPicture({ state: 'working', size: 40 });
  const searching = useThinkingOrbPicture({ state: 'searching', size: 40 });

  return (
    <Canvas style={{ width: 96, height: 40 }}>
      <Picture picture={working} />
      <Group transform={[{ translateX: 56 }]}>
        <Picture picture={searching} />
      </Group>
    </Canvas>
  );
}

How do you build a voice agent orb?

A voice agent has a different problem. It is not showing one of six activities, it is showing a session lifecycle, and the user needs to know whether the thing is connecting, listening to them, thinking, or talking. Those states also need to be distinguishable while the user is not looking directly at the screen.

VoiceOrbState is LiveKit's AgentState union verbatim, nine plain strings, so a session state passes straight through with no mapping table:

import { VoiceOrb, useVoiceAmplitude } from 'expo-thinking-orbs';

function AgentAvatar() {
  const { state } = useVoiceAssistant(); // '@livekit/components-react'
  const mic = useVoiceAmplitude();
  const agent = useVoiceAmplitude();

  return (
    <VoiceOrb
      state={state}
      inputAmplitude={mic.level}
      outputAmplitude={agent.level}
      size={180}
    />
  );
}

Nine states map onto eight behaviours, since failed reuses the disconnected shell but frozen. They are staged deliberately: each step along disconnected → connecting → pre-connect-buffering → initializing → idle is measurably fuller and brighter than the last, so connection progress is legible without a label. All eight act on one shared dot shell, which means a state change blends over about 420ms instead of cutting.

Feeding it audio is a separate concern from rendering it. useVoiceAmplitude() owns a shared value the orb reads every frame and converts whichever format you actually have:

Your source Call
Already 0 to 1 (LiveKit useTrackVolume, a VU meter) mic.set(v)
dBFS (expo-audio metering, expo-av) mic.setDb(db)
Raw PCM frames in −1 to 1 (a Realtime stream) agent.setSamples(frames)

Setting it never re-renders React. Feed a raw meter rather than a smoothed one: levels are already smoothed on the UI thread with a fast attack around 45ms and a slow release around 240ms, so pre-smoothing on top only makes the orb lag the voice.

Why amplitude changes depth, not speed

The tempting wiring is to make a louder voice animate faster. It reads badly. Driving the rate from amplitude is frequency modulation, and the eye reads frequency modulation as vibration or as anxiety, not as speech. Drive depth instead: hold the tempo fixed and let the audio level scale how far each gesture travels. The orb then feels like it is responding to a voice rather than being shaken by one.

The direction is worth using too. Wavefronts that converge inward read as taking something in, which is listening; wavefronts that expand outward read as putting something out, which is speaking. Same shell, opposite sign, and no label needed.

What does reduce motion mean for a loading indicator?

The reflex is to freeze the animation, and for a loading indicator that reflex is wrong twice over.

A frozen loading indicator stops communicating that anything is happening, which is its entire job. Worse, if your states are distinguished partly by motion, freezing collapses them: idle, listening and thinking share a resting radius by design, and it is the movement that tells them apart. Freeze the shell and three different states render as the same picture.

Reduced motion asks for less motion, not none. Slowing to roughly a third of pace and holding the audio response constant keeps every distinction alive while removing the part that causes discomfort. Keep an explicit paused prop for the cases where you genuinely do want a still image.

The 120Hz gotcha on iPhone Pro

iOS caps CADisplayLink at 60fps unless the app opts in, so on a ProMotion display your carefully UI-threaded animation runs at half the refresh rate the panel can do. This is an app-level setting no library can turn on for you.

// app.json
{ "expo": { "ios": { "infoPlist": { "CADisableMinimumFrameDuration": true } } } }

Bare React Native sets the same CADisableMinimumFrameDuration key in Info.plist. Android has no equivalent opt-in, since the system negotiates refresh rate itself. Do note what you are signing up for: opting in halves your per-frame budget from 16.7ms to 8.3ms for the same work, so if you render several orbs at once, share one canvas first.

How do you show an AI thinking indicator in React Native?

Use a component whose shape changes with the kind of work being done, and switch it as your agent changes activity: one animation while it calls a search tool, another while it reasons, another while it streams a reply. A single spinner cannot express that difference, and the difference is what gives the user a reason to keep waiting. Render it on the UI thread with Skia and Reanimated rather than from JavaScript state, because an agent UI is busy re-rendering a streaming message list at exactly the moment the indicator is on screen. expo-thinking-orbs packages six such states plus a voice orb.

Why does my loading animation stutter while the AI response streams?

Because the animation and the token stream are sharing the JavaScript thread. Every streamed chunk triggers a state update and a re-render of the message list, and an animation driven by setState, by requestAnimationFrame, or by an Animated value without useNativeDriver has to wait its turn behind that work. Move the animation to the UI thread with Reanimated worklets, or to Skia drawing from a worklet, and the stream can saturate the JS thread without touching the frame rate.

Can you use a shader for a dotted orb animation?

You can, and it is usually slower on mobile. A fragment shader runs per pixel, so each invocation has to test every dot to know whether that pixel falls inside one, which multiplies a few hundred dots by a few hundred thousand pixels every frame. Computing a few hundred dot positions on the CPU and handing Skia a few hundred circles scales with the dot count alone. Keep the math on the CPU and move it to the UI thread instead of to the GPU.

Does expo-thinking-orbs work with LiveKit?

Yes, with no adapter. Its VoiceOrbState is LiveKit's AgentState union verbatim, so the state returned by useVoiceAssistant() can be passed straight to <VoiceOrb>. Audio levels come in separately through useVoiceAmplitude(), which accepts LiveKit's useTrackVolume output directly, or dBFS metering, or raw PCM frames. Other voice SDKs work too, since the union is nine plain strings you can map onto.

Should a voice orb animate faster when the user speaks louder?

No. Scaling animation rate with amplitude is frequency modulation, and it reads as vibration or anxiety rather than as speech. Hold the tempo fixed and let the audio level scale the depth of each gesture, so a louder voice pushes the shape further rather than shaking it. Direction carries meaning as well: wavefronts converging inward read as listening, wavefronts expanding outward read as speaking.

Should loading animations stop when reduce motion is on?

Not entirely, in most cases. A frozen loading indicator stops communicating that work is in progress, which defeats its purpose, and if any of your states are distinguished by their movement rather than their shape, freezing makes them indistinguishable. Slowing to roughly a third of the normal pace and suppressing audio-reactive motion respects the setting while preserving the information. Offer a separate explicit prop for callers who really do want a still frame.

The short version

Give an agent UI more than one loading shape, because the verb is the information the user wants. Keep the animation on the UI thread, because an agent UI is busiest on the JS thread exactly while the indicator is visible. Keep the per-frame math on the CPU rather than reaching for a shader, and keep it allocation-free so it does not trigger collections mid-stream. Drive audio into depth rather than into speed. And under reduce motion, slow down rather than stop.

All of that is packaged in expo-thinking-orbs, free and MIT, ported from Jakub Antalik's thinking-orbs whose animation design and engine math the six states come from. For more React Native components built with this much attention to which thread the work lands on, the component reference is free and the catalog has the rest.

React NativeExpoSkiaReanimatedAIVoiceAccessibility