DEV Community

Cover image for How I gave my AI agent a responsive face with pure React (SVG, 3D, zero backend)
Ariel Alejandro
Ariel Alejandro

Posted on

How I gave my AI agent a responsive face with pure React (SVG, 3D, zero backend)

Text transcripts tell you what an LLM is doing, but they feel static and heavy to read. I built a React component that transforms AI agent interactions by adding a responsive face — supporting custom SVGs, built-in vector avatars, and optional 3D heads — that visibly listens, thinks, speaks, and reports tool usage, with zero backend setup.

react-ai-avatar

Why text transcripts feel incomplete for AI interactions

If you build apps with LLMs, user interaction usually boils down to a chat window and a stream of text.

When the agent runs long tasks, modern UIs show status text: Thinking…, Searching the web, or Running bash command. That's a huge improvement over a static spinner, but it still leaves the interaction feeling like reading a terminal log.

Every status update competes for the exact same column as the final answer you're waiting to read.

I wanted to see if I could transform that interaction: What if your AI agent had a visual face that reports its condition out of the corner of your eye?

Not a heavy 3D game character, and not a $99/month SaaS widget — just a clean, provider-agnostic React component that you drop into your existing frontend.

The idea: One React component, any avatar style

The goal was to make adding an avatar to an AI app as simple as installing the package and rendering a component:

npm install react-ai-avatar
Enter fullscreen mode Exit fullscreen mode
<RealtimeAvatar
  state={status === 'streaming' ? 'speaking' : 'idle'}
  streamingText={lastAssistantMessage}
/>
Enter fullscreen mode Exit fullscreen mode

No API keys, no backend proxy, and no mandatory external libraries.

Depending on your product's design system, you can choose how the avatar renders:

  • Built-in 2D Vector (Default): A clean SVG face with zero dependencies that keeps your bundle tiny.
  • Your Own Custom SVG: Pass custom React SVG elements to match your product's brand mascot or design system.
  • 3D Head Adapter: An optional Three.js adapter if you want a 3D model, lazy-loaded so it won't inflate bundle size if you don't use it.

Whether your app connects to OpenAI, Claude, Ollama, Gemini, or a custom WebSocket server, the avatar doesn't care. You bring the connection; it brings the face.

The 5 agent states: What the avatar reports

Instead of guessing your backend logic, the component relies on your app passing one of five clear execution states:

State What the avatar visibly shows
idle Neutral, relaxed posture; periodic natural blinking
listening Eyes tracking user cursor / input focus
thinking Animated thought indicator while waiting for the first token
speaking Reactive mouth movements synced to incoming content
working Tool-use mode (displays active tool labels like "searching" or "executing script")

Because the host application controls the state, you never get weird race conditions or false assumptions about what the LLM is doing.

Lip-sync without audio: Rhythm from token streams

The hardest technical part of making an avatar feel alive is the mouth. A mouth that flaps at a fixed speed while text streams looks fake immediately.

If your app uses voice, an AnalyserNode can pass volume levels to drive mouth height. But most LLM applications stream plain text over SSE (Server-Sent Events).

I realized that text streams already carry natural speech rhythm. Tokens arrive in bursts, pause when the model processes, and speed up during continuous generation.

To capture that cadence without requiring an audio stream or TTS backend, I built a leaky accumulator that converts raw text arrivals into an energy signal:

const DEFAULTS = { chargePerChar: 0.12, decayMs: 140, maxChargePerPush: 0.9 };

export function createSpeechActivity(options: SpeechActivityOptions = {}) {
  const chargePerChar = options.chargePerChar ?? DEFAULTS.chargePerChar;
  const decayMs = options.decayMs ?? DEFAULTS.decayMs;
  const maxChargePerPush = options.maxChargePerPush ?? DEFAULTS.maxChargePerPush;
  const now = options.now ?? (() => performance.now());

  let energy = 0;
  let lastT = now();

  const decayTo = (t: number) => {
    const dt = t - lastT;
    lastT = t;
    if (dt > 0) energy *= Math.exp(-dt / decayMs);
  };

  return {
    push(textChunk: string) {
      if (!textChunk) return;
      decayTo(now());
      energy = Math.min(1, energy + Math.min(maxChargePerPush, textChunk.length * chargePerChar));
    },
    end() {
      // Energy decays naturally on the next sample().
    },
    reset() { energy = 0; lastT = now(); },
    /** Current decayed energy (0 to 1). Read once per frame. */
    sample(): number {
      decayTo(now());
      return energy < 0.001 ? 0 : Math.min(1, energy);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Key choices that make the animation feel natural:

  • Charge cap (maxChargePerPush): Prevents large text chunks from locking the mouth open.
  • Exponential decay (Math.exp): Mimics acoustic energy falloff instead of linear mechanical closing.
  • Sampled timing: Calculates decay dynamically on animation frames, avoiding timer overhead and staying smooth even if tab execution slows down.

For React hooks that pass full accumulated strings ("Hello world"), a light helper (diffStreamingText) calculates new character pushes cleanly without extra state tracking in your app.

State, not simulated emotion

A critical decision when adding an avatar to AI software is drawing a boundary between system state and simulated emotion:

  • The avatar shows what the software is doing (thinking, speaking, working).
  • It does not attempt to pretend the AI has feelings, simulated warmth, or fake expressions.

Keeping the avatar focused on feedback rather than fake personality avoids the uncanny valley and keeps the tool honest.

Try it out

All of this is packed into react-ai-avatar (MIT license):

How are you currently handling visual interactions with LLM agent runs?

Top comments (0)