States vs patterns
A pattern is how a visual looks. A state is where your app is. Keep them apart and one design follows your whole flow.
Every visual has a pattern: the look it draws. breathing is a slow ribbon of dots, speaking is a spectrum of bars, tracking is a set of activity rings. A pattern belongs to one object (orb, ring, signal, core or beacon) and never changes on its own.
Your app has states: idle, listening, thinking, speaking, goalReached, whatever your product calls them. A state is a name you choose. It picks which pattern (and which colours, materials and bindings) to show.
One pattern
The smallest visual is an object and a pattern. In a view it's two props, and in an FX Spec file two keys:
{
"fxSpec": "1.8",
"object": "orb",
"pattern": "breathing"
}
Browse every pattern in the catalog.
States: one design for your whole flow
Add a states map when the look should follow your app. The top level is the base design. Each state is a patch over it: it overrides what it names and inherits the rest. null removes an inherited value.
{
"fxSpec": "1.8",
"name": "Voice orb",
"description": "One shape for the whole conversation: the pattern stays `glowing` and the agent's state changes how it behaves -- speed, density, glow, particles, `ink` and which voice drives it.",
"object": "orb",
"pattern": "glowing",
"size": 64,
"ink": 1,
"color": {
"value": "#6E56CF",
"mix": 0.92
},
"params": {
"hueSpread": 30,
"hueSpeed": 0,
"surfaceScale": 1.2,
"surfaceSpeed": 0.1,
"nodeCount": 220,
"nodeSize": 1.15,
"saturation": 0.5
},
"materials": {
"glow": {
"strength": 0.25,
"radius": 3
}
},
"bindings": {
"muted": {
"input": "micMuted"
}
},
"states": {
"initializing": {
"speed": 0.8,
"params": {
"nodeCount": 160,
"nodeSize": 1,
"surfaceSpeed": 0.6,
"saturation": 0.4
},
"ink": 0.7
},
"idle": {
"speed": 0.6,
"materials": {
"pulse": {
"strength": 0.6,
"period": 4.5,
"scale": 0.04,
"opacity": 0.25
}
},
"ink": 0.8
},
"listening": {
"speed": 0.9,
"params": {
"nodeCount": 260,
"nodeSize": 1.3,
"surfaceSpeed": 0.28,
"saturation": 0.6
},
"materials": {
"glow": {
"strength": 0.3
},
"particles": {
"strength": 1,
"style": "attract",
"count": 36,
"size": 1.4,
"spread": 0.4,
"life": 1.8,
"audio": 1
}
},
"bindings": {
"audioLevel": {
"input": "micLevel",
"curve": "easeOut"
}
}
},
"thinking": {
"speed": 1.05,
"params": {
"nodeCount": 340,
"nodeSize": 1.05,
"surfaceScale": 3,
"surfaceSpeed": 0.55,
"saturation": 0.8,
"hueSpeed": 0
},
"materials": {
"glow": {
"strength": 0.28
},
"pulse": {
"strength": 0.6,
"period": 1.8,
"scale": 0.015,
"opacity": 0.45
}
}
},
"speaking": {
"speed": 1.15,
"params": {
"nodeCount": 260,
"nodeSize": 1.5,
"surfaceScale": 1.5,
"surfaceSpeed": 0.5,
"saturation": 0.62
},
"materials": {
"glow": {
"strength": 0.45
},
"particles": {
"strength": 1,
"style": "drift",
"count": 32,
"life": 1.4,
"audio": 1
}
},
"bindings": {
"audioLevel": {
"input": "agentVolume",
"inputRange": [
0,
0.8
]
}
}
}
}
}The four agent states come with the engine: name them in states and the visual already rests when idle, draws inward on the mic while listening, works quietly while thinking and swells with the agent's voice while speaking. You only write what you want to change. It works without a file too — give a view a pattern and the current state, and the same behaviour applies:
import { useState } from "react";
import { SinuaView } from "@sinua/web/react";
import type { AgentState } from "@sinua/core";
// No spec file: one pattern, and the agent's state moves it (idle breathes and dims,
// listening draws inward, thinking stirs, speaking swells).
export function AssistantOrb({ voice }: { voice?: import("@sinua/core").VoiceSource }) {
const [state, setState] = useState<AgentState>("idle");
// With a `voice` the view follows the source's state on its own; this is the manual way.
void setState;
return <SinuaView pattern="working" state={state} voice={voice} style={{ width: 160, height: 160 }} />;
}
A state name of your own ("recording") is left alone: the visual stays the bare pattern. The repository's docs/fx-view.md (Voice states without a spec) has the full precedence table — plain input, a 1.8+ spec, an older spec, and your own names.
Here the pattern never changes. One shape stays on screen for the whole conversation, and each state changes how it behaves: how fast it moves, how dense and bright it is, whether the mic or the agent's voice drives it. That's what we recommend for a voice assistant, because the visual stays one recognisable thing.
Pass the current state to the view (state) and it renders that entry. A state the file doesn't list renders the base design.
A state can also change the pattern
If you want a different shape per state, name a pattern inside the state. The view cross-fades between the two (0.25 s by default).
{
"fxSpec": "1.8",
"name": "Voice orb (a pattern per state)",
"object": "orb",
"pattern": "breathing",
"size": 64,
"color": {
"value": "#6E56CF",
"mix": 0.7
},
"bindings": {
"muted": {
"input": "micMuted"
}
},
"states": {
"idle": {},
"listening": {
"pattern": "listening",
"bindings": {
"audioLevel": {
"input": "micLevel",
"curve": "easeOut"
}
}
},
"thinking": {
"pattern": "working"
},
"speaking": {
"pattern": "speaking",
"bindings": {
"audioLevel": {
"input": "agentVolume",
"inputRange": [
0,
0.8
]
}
}
}
},
"description": "The advanced form: each state picks a different pattern."
}
import { SinuaView } from "@sinua/web/react";
import type { VoiceSource } from "@sinua/core";
import voiceOrb from "../spec/voice-orb.fxspec.json";
// The spec's `states` hold one look per lifecycle state. With a voice attached, the
// view follows the agent's state ("listening", "thinking", "speaking", ...) on its own;
// pass `state` to drive it yourself instead.
export function AssistantOrb({ voice, state }: { voice?: VoiceSource; state?: string }) {
return <SinuaView spec={voiceOrb} voice={voice} state={state} style={{ width: 160, height: 160 }} />;
}
Voice agents: use the agent's state names
For a voice agent, name your states after the agent's lifecycle. They match LiveKit's AgentState, and every voice source in Sinua reports them:
| State | What the agent is doing | A good pattern |
|---|---|---|
initializing | connecting | initializing |
idle | connected, nobody talking | the base design |
listening | hearing the user | listening |
thinking | working out a reply | working |
speaking | talking | speaking |
With a voice attached, the view follows the agent's state automatically. Pass state yourself only to override it. See Voice.
Rules of thumb
- Pick patterns by meaning, not by looks. A user reads a spinner as "wait" and a spectrum as "sound". The catalog's when to use notes say what each pattern means.
- Keep the base design calm. It's what shows when nothing is happening, and whenever a state isn't in the map.
- Change look through states, data through values. A state switch is a discrete moment. A value (progress, audio level) moves continuously inside one pattern. See Values.