# lalia
The visual layer for voice AI: fluid, audio-reactive orbs for voice agents. Nine designs, zero dependencies, raw WebGL2.
```bash
npm install lalia
```
## Quick start
Make the orb its own small component, then give it your voice session:
```tsx
'use client'
import type { AudioSampler, OrbState } from 'lalia'
import { Orb } from 'lalia/react'
import { wisp } from 'lalia/variants'
export function AgentOrb(live: { state: OrbState; input?: AudioSampler | null; output?: AudioSampler | null }) {
return
}
```
```tsx
import { fromStream } from 'lalia'
```
- `state` is what the agent is doing (see [States](#states)).
- `input` is the user's microphone, `output` is the agent's voice (see [Audio](#audio)).
- With only `state`, the orb still breathes and moves; audio makes it react to speech.
- Using Next.js App Router: keep `'use client'` at the top of that file. Designs and audio samplers are functions, so they can't cross from a server component.
## Designs
| Family | Designs |
| --- | --- |
| Mist | `wisp` · `haze` · `cloud` |
| Silk | `silk` · `satin` |
| Glass | `glass` · `prism` |
| Lumen | `lumen` |
| Chrome | `chrome` |
Import them by name from `lalia/variants`. `variants` holds all of them, for picking one by a string: `variants[name]` (undefined for an unknown name).
## Colors
Every design has its own palette. Pass a `scheme` and every design wears it the way it was meant to:
```tsx
import { sage } from 'lalia/schemes'
```
Curated schemes, grouped in `schemeGroups` and keyed by name in `schemes`:
| Group | Schemes |
| --- | --- |
| Neutral | `graphite` · `pearl` · `linen` · `glacier` |
| Hue | `cobalt` · `iris` · `orchid` · `rose` · `coral` · `ember` · `honey` · `citrus` · `sage` · `jade` · `lagoon` |
| Atmosphere | `dusk` · `dawn` · `borealis` · `nocturne` · `abyss` · `opal` · `cosmic` |
All schemes share one lightness ladder, so switching schemes never makes an orb heavier or lighter, and each carries light-page versions of its pale tones.
Brand color? `createScheme` keeps its hue exactly and fits lightness and chroma to the orb:
```ts
import { createScheme } from 'lalia/schemes'
const brand = createScheme('#ff5a1f') // hex or rgb() also work on the server
const bold = createScheme('#ff5a1f', { harmony: 'complementary' }) // 'analogous' | 'complementary' | 'split' | 'triadic'
const quiet = createScheme('#ff5a1f', { vibrance: 0.4 }) // 0 is nearly grey, 1 is as vivid as sRGB allows
```
`colors` takes up to four CSS colors used as-is and wins over `scheme`.
## Light and dark pages
On dark pages an orb is a light source; on light pages it behaves like ink, with tinted haze instead of glow and deeper color. By default (`appearance="auto"`) it reads the background color of the elements behind it and eases between the two when your theme changes. Over an image or video, or anywhere the guess is wrong, set `appearance="light"` or `"dark"`.
## States
`idle` · `connecting` · `listening` · `thinking` · `speaking` · `muted`
Switching states is always a glide: every value springs toward the new state's targets. Tune a state with `motion`, or the spring with `transition`:
```tsx
```
## Audio
`input` and `output` take an `AudioSampler`: a function the orb calls every frame, returning a 0–1 level or `{ level, low, mid, high }`. The orb follows `input` while listening and `output` while speaking.
```ts
import { fromStream, fromNode, createVoiceSimulator } from 'lalia'
fromStream(micStream) // any MediaStream: getUserMedia, a remote WebRTC track
fromNode(playbackGain) // any AudioNode your agent's audio plays through
createVoiceSimulator() // fake speech, for demos and design work
```
- The same stream or node always gives the same sampler, so writing `fromStream(…)` or `fromNode(…)` inline in JSX is fine.
- A stream with no audio track yet (a remote track that arrives later) reads as silence until it has one.
- Browsers keep audio paused until the user has clicked or tapped the page; the sampler starts by itself once they have. Starting a voice session is usually that click.
- `sampler.dispose?.()` releases the audio nodes when you're done. A disposed sampler that is read again reconnects.
A complete example with a microphone and audio you play yourself:
```tsx
'use client'
import { useEffect, useState } from 'react'
import { fromNode, fromStream, type OrbState } from 'lalia'
import { Orb } from 'lalia/react'
import { wisp } from 'lalia/variants'
export function VoiceOrb({ state, playback }: { state: OrbState; playback: GainNode }) {
const [mic, setMic] = useState(null)
useEffect(() => {
let stream: MediaStream | undefined
navigator.mediaDevices.getUserMedia({ audio: true }).then((s) => setMic((stream = s)))
return () => stream?.getTracks().forEach((t) => t.stop())
}, [])
return
}
```
## Adapters
Ready-made state mapping for three voice platforms.
**ElevenLabs** (`@elevenlabs/react`): audio and state both come from the conversation.
```tsx
import { elevenLabsAudio, elevenLabsState } from 'lalia/adapters/elevenlabs'
function VoiceOrb() {
const conversation = useConversation()
const audio = useMemo(() => elevenLabsAudio(conversation), [conversation])
return
}
```
**OpenAI Realtime** over WebRTC: feed every data-channel event to the reducer.
```tsx
import { openaiRealtimeState } from 'lalia/adapters/openai'
function VoiceOrb({ dc, micStream, remoteStream }: { dc: RTCDataChannel; micStream: MediaStream; remoteStream: MediaStream }) {
const [state, setState] = useState('idle')
useEffect(() => {
const onEvent = (e: MessageEvent) => setState((s) => openaiRealtimeState(JSON.parse(e.data), s))
dc.addEventListener('message', onEvent)
return () => dc.removeEventListener('message', onEvent)
}, [dc])
return
}
```
Chrome only feeds a remote WebRTC stream into Web Audio while it is also playing, so keep your `