AvatarStage
Render multiple avatars in a shared container with automatic layout.
AvatarStage is a renderer-agnostic layout and lifecycle manager for multiple avatars. It creates positioned child elements inside a container, mounts each avatar's renderer into its own slot, and handles layout reflow when avatars are added or removed. It works with any AvatarRenderer implementation — VRM, Live2D, remote video, or custom renderers can all coexist on the same stage.
Quick start
import { AvatarStage } from "avatarlayer";
import { VRMLocalRenderer } from "avatarlayer/renderers";
const stage = new AvatarStage({ layout: "row" });
stage.mount(document.getElementById("stage-root")!);
const drake = new VRMLocalRenderer({ modelUrl: "/drake.vrm" });
const ann = new VRMLocalRenderer({ modelUrl: "/ann.vrm" });
await stage.add(drake, { id: "drake" });
await stage.add(ann, { id: "ann" });Both avatars render side-by-side in the same container, each in its own equally-sized slot.
How it works
The stage applies CSS flexbox to the container element. When you call add(), it creates a child <div> and calls renderer.mount() on it. Each slot gets equal space via flex: 1 1 0. Removing an avatar calls renderer.unmount() and removes the child element.
┌─────────────────────────────────────────┐
│ AvatarStage container │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ slot "drake" │ │ slot "ann" │ │
│ │ VRMRenderer │ │ VRMRenderer │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘Constructor options
interface AvatarStageOptions {
layout?: "row" | "stack";
gap?: number;
}| Option | Type | Default | Description |
|---|---|---|---|
layout | "row" | "stack" | "row" | "row" places avatars side-by-side; "stack" places them vertically |
gap | number | 0 | Gap in pixels between avatar slots |
Methods
| Method | Description |
|---|---|
mount(container) | Attach the stage to a DOM element. Applies flexbox layout styles. |
add(renderer, opts?) | Mount a renderer into a new slot. Returns the slot id (auto-generated or from opts.id). |
remove(idOrRenderer) | Remove an avatar by slot id or renderer reference. Calls unmount() on the renderer. |
get(id) | Get a renderer by slot id. Returns undefined if not found. |
unmount() | Remove all avatars and detach from the container. |
AddAvatarOptions
interface AddAvatarOptions {
id?: string;
}| Option | Type | Description |
|---|---|---|
id | string | User-defined identifier for this slot. Auto-generated UUID when omitted. |
Properties
| Property | Type | Description |
|---|---|---|
mounted | boolean | Whether the stage is attached to a container |
avatars | ReadonlyMap<string, AvatarRenderer> | Map of slot id to renderer |
Events
stage.on("avatar-added", (avatar, id) => { /* ... */ });
stage.on("avatar-removed", (id) => { /* ... */ });| Event | Payload | Description |
|---|---|---|
avatar-added | (avatar: AvatarRenderer, id: string) | Fired after a renderer is mounted into a slot |
avatar-removed | (id: string) | Fired after a renderer is unmounted and its slot removed |
Using with AvatarSession
Each avatar on a stage can be driven by its own independent AvatarSession. The stage manages rendering layout; the sessions manage the conversational pipeline:
import { AvatarSession, AvatarStage } from "avatarlayer";
import { VRMLocalRenderer } from "avatarlayer/renderers";
import { TransportLLM, TransportTTS } from "avatarlayer/transport";
const stage = new AvatarStage({ layout: "row", gap: 8 });
stage.mount(document.getElementById("stage-root")!);
const drakeRenderer = new VRMLocalRenderer({ modelUrl: "/drake.vrm" });
const annRenderer = new VRMLocalRenderer({ modelUrl: "/ann.vrm" });
await stage.add(drakeRenderer, { id: "drake" });
await stage.add(annRenderer, { id: "ann" });
const drakeSession = new AvatarSession({
llm: new TransportLLM({ url: "/api/llm" }),
tts: new TransportTTS({ url: "/api/tts" }),
renderer: drakeRenderer,
systemPrompt: "You are Drake, a confident strategist.",
});
const annSession = new AvatarSession({
llm: new TransportLLM({ url: "/api/llm" }),
tts: new TransportTTS({ url: "/api/tts" }),
renderer: annRenderer,
systemPrompt: "You are Ann, a sharp-eyed analyst.",
});
// Send the same question to both characters
async function askBoth(text: string) {
await Promise.all([
drakeSession.sendMessage(text),
annSession.sendMessage(text),
]);
}Note that when a renderer is already mounted on a stage, you should not call session.start(container) — the renderer is already mounted. Instead, use the session's other lifecycle methods (sendMessage, interrupt, etc.) directly. If you need start() to handle thread loading and greeting, mount the stage first, then create sessions with the already-mounted renderers.
Mixed renderer types
Any AvatarRenderer can be added to a stage. VRM, Live2D, and remote video renderers can coexist:
import { AvatarStage } from "avatarlayer";
import { VRMLocalRenderer, Live2DRenderer } from "avatarlayer/renderers";
const stage = new AvatarStage();
stage.mount(container);
await stage.add(
new VRMLocalRenderer({ modelUrl: "/character-3d.vrm" }),
{ id: "vrm-char" },
);
await stage.add(
new Live2DRenderer({ modelUrl: "/character-2d.model.json" }),
{ id: "live2d-char" },
);Dynamic add and remove
Avatars can be added and removed at any time after the stage is mounted:
const id = await stage.add(new VRMLocalRenderer({ modelUrl: "/new.vrm" }));
// Later...
stage.remove(id);
// or by renderer reference:
stage.remove(renderer);The layout automatically reflows — remaining avatars expand to fill the available space.