Skip to content
edmi-ui

Persona

Animated agent persona (Rive) reacting to idle, listening, thinking, speaking and asleep states.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Rive artwork loads from the network. Variants: obsidian, mana, opal, halo, glint, command.

Rive artwork loads from the network. Variants: obsidian, mana, opal, halo, glint, command.

Rive artwork loads from the network. Variants: obsidian, mana, opal, halo, glint, command.

The Rive artwork is fetched from the network at runtime, so the canvas stays empty while offline or until the file has loaded.

Terminal window
npx shadcn@latest add @edmi-ui/ai-persona
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-persona
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-persona
Terminal window
bunx shadcn@latest add @edmi-ui/ai-persona

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-persona
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-persona
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-persona
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-persona

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Persona } from "@/components/ai/persona";
<div className="flex flex-col items-center gap-5">
<Persona className="size-24" variant="obsidian" />
<div className="flex flex-wrap justify-center gap-1.5">
</div>
<p className="max-w-sm text-center text-xs text-muted-foreground">
Rive artwork loads from the network. Variants: obsidian, mana, opal,
halo, glint, command.
</p>
</div>
<script setup lang="ts">
import { Persona } from "@/components/ai/persona";
</script>
<template>
<div class="flex flex-col items-center gap-5">
<Persona class="size-24" variant="obsidian" />
<div class="flex flex-wrap justify-center gap-1.5">
<button
:key="s"
type="button"
class="rounded-md border border-border px-2.5 py-1 font-mono text-[11.5px] text-muted-foreground data-[active=true]:bg-accent data-[active=true]:text-foreground"
>
{{ s }}
</button>
</div>
<p class="max-w-sm text-center text-xs text-muted-foreground">
Rive artwork loads from the network. Variants: obsidian, mana, opal, halo, glint, command.
</p>
</div>
</template>
<script lang="ts">
import { Persona } from "$lib/components/ai/persona";
</script>
<div class="flex flex-col items-center gap-5">
<Persona class="size-24" variant="obsidian" />
<div class="flex flex-wrap justify-center gap-1.5">
{#each states as s (s)}
<button
type="button"
class="rounded-md border border-border px-2.5 py-1 font-mono text-[11.5px] text-muted-foreground data-[active=true]:bg-accent data-[active=true]:text-foreground"
>
{s}
</button>
{/each}
</div>
<p class="max-w-sm text-center text-xs text-muted-foreground">
Rive artwork loads from the network. Variants: obsidian, mana, opal, halo, glint, command.
</p>
</div>
registry/components/ai/persona.tsx
"use client";
import type { RiveParameters } from "@rive-app/react-webgl2";
import {
useRive,
useStateMachineInput,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor,
} from "@rive-app/react-webgl2";
import { cn } from "cn";
import type { FC, ReactNode } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
// Delays Rive initialization by one frame so that React Strict Mode's
// immediate unmount cycle never creates a WebGL2 context. Only the
// second (real) mount will initialise, avoiding context exhaustion.
const useStrictModeSafeInit = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setReady(true));
return () => {
cancelAnimationFrame(id);
setReady(false);
};
}, []);
return ready;
};
// Edmi: the Rive artwork is the same; only the wrapper data attributes and the scoped dark-mode detection differ.
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
interface PersonaProps {
state: PersonaState;
onLoad?: RiveParameters["onLoad"];
onLoadError?: RiveParameters["onLoadError"];
onReady?: () => void;
onPause?: RiveParameters["onPause"];
onPlay?: RiveParameters["onPlay"];
onStop?: RiveParameters["onStop"];
className?: string;
variant?: keyof typeof sources;
}
// The state machine name is always 'default' for Elements AI visuals
const stateMachine = "default";
const sources = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
},
};
// Edmi themes with a `.dark` class, on <html> or on any ancestor (scoped dark subtrees), so look at the
// element first and fall back to the document and the OS preference.
const getCurrentTheme = (element?: Element | null): "light" | "dark" => {
if (typeof window !== "undefined") {
if (element?.closest(".dark")) {
return "dark";
}
if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (
!element?.closest(".light") &&
window.matchMedia?.("(prefers-color-scheme: dark)").matches
) {
return "dark";
}
}
return "light";
};
const useTheme = (enabled: boolean, element: Element | null) => {
const [theme, setTheme] = useState<"light" | "dark">(() =>
getCurrentTheme(element),
);
useEffect(() => {
setTheme(getCurrentTheme(element));
// Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
if (!enabled) {
return;
}
// Watch for class changes anywhere above the element
const observer = new MutationObserver(() => {
setTheme(getCurrentTheme(element));
});
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
subtree: true,
});
// Watch for OS-level theme changes
let mql: MediaQueryList | null = null;
const handleMediaChange = () => {
setTheme(getCurrentTheme(element));
};
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)");
mql.addEventListener("change", handleMediaChange);
}
return () => {
observer.disconnect();
if (mql) {
mql.removeEventListener("change", handleMediaChange);
}
};
}, [enabled, element]);
return theme;
};
interface PersonaWithModelProps {
rive: ReturnType<typeof useRive>["rive"];
element: Element | null;
source: (typeof sources)[keyof typeof sources];
children: React.ReactNode;
}
const PersonaWithModel = memo(
({ rive, element, source, children }: PersonaWithModelProps) => {
const theme = useTheme(source.dynamicColor, element);
const viewModel = useViewModel(rive, { useDefault: true });
const viewModelInstance = useViewModelInstance(viewModel, {
rive,
useDefault: true,
});
const viewModelInstanceColor = useViewModelInstanceColor(
"color",
viewModelInstance,
);
useEffect(() => {
if (!(viewModelInstanceColor && source.dynamicColor)) {
return;
}
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
viewModelInstanceColor.setRgb(r, g, b);
}, [viewModelInstanceColor, theme, source.dynamicColor]);
return children;
},
);
PersonaWithModel.displayName = "PersonaWithModel";
interface PersonaWithoutModelProps {
children: ReactNode;
element?: Element | null;
rive?: unknown;
source?: unknown;
}
const PersonaWithoutModel = memo(
({ children }: PersonaWithoutModelProps) => children,
);
PersonaWithoutModel.displayName = "PersonaWithoutModel";
export const Persona: FC<PersonaProps> = memo(
({
variant = "obsidian",
state = "idle",
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
className,
}) => {
const source = sources[variant];
if (!source) {
throw new Error(`Invalid variant: ${variant}`);
}
// Stabilize callbacks to prevent useRive from reinitializing
const callbacksRef = useRef({
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
});
useEffect(() => {
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
};
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
const stableCallbacks = useMemo(
() => ({
onLoad: ((loadedRive) =>
callbacksRef.current.onLoad?.(
loadedRive,
)) as RiveParameters["onLoad"],
onLoadError: ((err) =>
callbacksRef.current.onLoadError?.(
err,
)) as RiveParameters["onLoadError"],
onPause: ((event) =>
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
onPlay: ((event) =>
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
onReady: () => callbacksRef.current.onReady?.(),
onStop: ((event) =>
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
}),
[],
);
// Delay initialisation by one frame to avoid creating (and leaking)
// a WebGL2 context during React Strict Mode's first throw-away mount.
const ready = useStrictModeSafeInit();
const { rive, canvas, RiveComponent } = useRive(
ready
? {
autoplay: true,
onLoad: stableCallbacks.onLoad,
onLoadError: stableCallbacks.onLoadError,
onPause: stableCallbacks.onPause,
onPlay: stableCallbacks.onPlay,
onRiveReady: stableCallbacks.onReady,
onStop: stableCallbacks.onStop,
src: source.source,
stateMachines: stateMachine,
}
: null,
);
const listeningInput = useStateMachineInput(
rive,
stateMachine,
"listening",
);
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
// Rive state machine inputs are mutable objects that must be set via direct
// property assignment — this is the intended Rive API, not a React anti-pattern.
useEffect(() => {
if (listeningInput) {
listeningInput.value = state === "listening";
}
if (thinkingInput) {
thinkingInput.value = state === "thinking";
}
if (speakingInput) {
speakingInput.value = state === "speaking";
}
if (asleepInput) {
asleepInput.value = state === "asleep";
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
return (
<Component element={canvas} rive={rive} source={source}>
<RiveComponent
className={cn("size-16 shrink-0", className)}
data-slot="ai-persona"
data-state={state}
data-variant={variant}
/>
</Component>
);
},
);
Persona.displayName = "Persona";
registry/components/ai/persona/Persona.vue
<script setup lang="ts">
import type { EventCallback, Rive } from "@rive-app/webgl2"
import type { HTMLAttributes } from "vue"
import type { PersonaState, PersonaVariant } from "./sources"
import { useResizeObserver } from "@vueuse/core"
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { personaSources } from "./sources"
// Rive/WebGL2 artwork; the same files as Vercel AI Elements. The look is the artwork, so it takes no elevation.
const props = withDefaults(defineProps<{
state?: PersonaState
variant?: PersonaVariant
class?: HTMLAttributes["class"]
}>(), {
state: "idle",
variant: "obsidian",
})
const emit = defineEmits<{
(e: "load"): void
(e: "loadError", error: unknown): void
(e: "ready"): void
(e: "pause", event: Parameters<EventCallback>[0]): void
(e: "play", event: Parameters<EventCallback>[0]): void
(e: "stop", event: Parameters<EventCallback>[0]): void
}>()
const stateMachine = "default"
const canvasRef = ref<HTMLCanvasElement | null>(null)
const rive = shallowRef<Rive | null>(null)
const source = computed(() => personaSources[props.variant])
// Edmi themes with a `.dark` class on <html> or on any ancestor (scoped dark subtrees).
const theme = ref<"light" | "dark">("light")
function readTheme(): "light" | "dark" {
if (canvasRef.value?.closest(".dark") || document.documentElement.classList.contains("dark"))
return "dark"
if (!canvasRef.value?.closest(".light") && window.matchMedia?.("(prefers-color-scheme: dark)").matches)
return "dark"
return "light"
}
let observer: MutationObserver | null = null
let mql: MediaQueryList | null = null
const syncTheme = () => { theme.value = readTheme() }
useResizeObserver(canvasRef, () => rive.value?.resizeDrawingSurfaceToCanvas())
function applyState() {
const instance = rive.value
if (!instance)
return
const inputs = instance.stateMachineInputs(stateMachine)
if (!inputs)
return
for (const name of ["listening", "thinking", "speaking", "asleep"] as const) {
const input = inputs.find((i) => i.name === name)
if (input)
input.value = props.state === name
}
}
function applyColor() {
const instance = rive.value
if (!instance || !source.value.dynamicColor || !source.value.hasModel)
return
const color = instance.viewModelInstance?.color("color")
if (color) {
const [r, g, b] = theme.value === "dark" ? [255, 255, 255] : [0, 0, 0]
color.rgb(r, g, b)
}
}
// @rive-app/webgl2 is CommonJS: load it on the client only (Node SSR cannot see its named exports).
type RiveModule = typeof import("@rive-app/webgl2")
let riveModule: Promise<RiveModule> | null = null
function loadRive() {
riveModule ??= import("@rive-app/webgl2").then(m =>
"Rive" in m ? m : (m as unknown as { default: RiveModule }).default,
)
return riveModule
}
let generation = 0
async function start() {
const run = ++generation
const { Rive } = await loadRive()
const canvas = canvasRef.value
if (run !== generation || !canvas)
return
rive.value?.cleanup()
const instance = new Rive({
canvas,
src: source.value.source,
autoplay: true,
autoBind: source.value.hasModel,
stateMachine,
onLoad: () => {
emit("load")
applyColor()
applyState()
emit("ready")
},
onLoadError: (err) => emit("loadError", err),
onPlay: (event) => emit("play", event),
onPause: (event) => emit("pause", event),
onStop: (event) => emit("stop", event),
})
rive.value = instance
}
onMounted(() => {
syncTheme()
observer = new MutationObserver(syncTheme)
observer.observe(document.documentElement, { attributeFilter: ["class"], attributes: true, subtree: true })
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)")
mql.addEventListener("change", syncTheme)
}
start()
})
onBeforeUnmount(() => {
generation++
observer?.disconnect()
mql?.removeEventListener("change", syncTheme)
rive.value?.cleanup()
rive.value = null
})
watch(() => props.state, applyState)
watch(theme, applyColor)
watch(() => props.variant, start)
</script>
<template>
<div
data-slot="ai-persona"
:data-state="props.state"
:data-variant="props.variant"
:class="cn('size-16 shrink-0', props.class)"
>
<canvas ref="canvasRef" class="size-full" />
</div>
</template>
registry/components/ai/persona/sources.ts
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
export type PersonaVariant =
| "command"
| "glint"
| "halo"
| "mana"
| "obsidian"
| "opal";
interface PersonaSource {
dynamicColor: boolean;
hasModel: boolean;
source: string;
}
/** Rive artwork per variant. The state machine is always `default`. */
export const personaSources: Record<PersonaVariant, PersonaSource> = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
},
};
registry/components/ai/persona/index.ts
export { default as Persona } from "./Persona.vue";
export type { PersonaState, PersonaVariant } from "./sources";
src/lib/registry/ai/persona/persona.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { EventCallback, Rive } from "@rive-app/webgl2";
import { onMount } from "svelte";
import { personaSources, type PersonaState, type PersonaVariant } from "./sources.js";
// Rive/WebGL2 artwork; the same files as Vercel AI Elements. The look is the artwork, so it takes no elevation.
let {
state: personaState = "idle",
variant = "obsidian",
class: className,
onLoad,
onLoadError,
onReady,
onPlay,
onPause,
onStop,
}: {
state?: PersonaState;
variant?: PersonaVariant;
class?: string;
onLoad?: () => void;
onLoadError?: (error: unknown) => void;
onReady?: () => void;
onPlay?: EventCallback;
onPause?: EventCallback;
onStop?: EventCallback;
} = $props();
const stateMachine = "default";
const source = $derived(personaSources[variant]);
let canvas = $state<HTMLCanvasElement | null>(null);
let rive: Rive | null = null;
let theme = $state<"light" | "dark">("light");
// Edmi themes with a `.dark` class on <html> or on any ancestor (scoped dark subtrees).
function readTheme(): "light" | "dark" {
if (canvas?.closest(".dark") || document.documentElement.classList.contains("dark")) return "dark";
if (!canvas?.closest(".light") && window.matchMedia?.("(prefers-color-scheme: dark)").matches)
return "dark";
return "light";
}
function applyState() {
const inputs = rive?.stateMachineInputs(stateMachine);
if (!inputs) return;
for (const name of ["listening", "thinking", "speaking", "asleep"] as const) {
const input = inputs.find((i) => i.name === name);
if (input) input.value = personaState === name;
}
}
function applyColor() {
if (!rive || !source.dynamicColor || !source.hasModel) return;
const color = rive.viewModelInstance?.color("color");
if (color) {
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
color.rgb(r, g, b);
}
}
// @rive-app/webgl2 is CommonJS: load it on the client only (Node SSR cannot see its named exports).
type RiveModule = typeof import("@rive-app/webgl2");
let riveModule: Promise<RiveModule> | null = null;
const loadRive = () =>
(riveModule ??= import("@rive-app/webgl2").then((m) =>
"Rive" in m ? m : (m as unknown as { default: RiveModule }).default,
));
let generation = 0;
async function start() {
const run = ++generation;
const { Rive } = await loadRive();
if (run !== generation || !canvas) return;
rive?.cleanup();
rive = new Rive({
canvas,
src: source.source,
autoplay: true,
autoBind: source.hasModel,
stateMachine,
onLoad: () => {
onLoad?.();
applyColor();
applyState();
onReady?.();
},
onLoadError: (err) => onLoadError?.(err),
onPlay: (event) => onPlay?.(event),
onPause: (event) => onPause?.(event),
onStop: (event) => onStop?.(event),
});
}
onMount(() => {
const syncTheme = () => {
theme = readTheme();
};
syncTheme();
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
subtree: true,
});
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
mql?.addEventListener("change", syncTheme);
const resize = new ResizeObserver(() => rive?.resizeDrawingSurfaceToCanvas());
if (canvas) resize.observe(canvas);
start();
return () => {
observer.disconnect();
mql?.removeEventListener("change", syncTheme);
resize.disconnect();
generation++;
rive?.cleanup();
rive = null;
};
});
let currentVariant: PersonaVariant | undefined;
$effect(() => {
const next = variant;
if (currentVariant !== undefined && next !== currentVariant) start();
currentVariant = next;
});
$effect(() => {
void personaState;
applyState();
});
$effect(() => {
void theme;
applyColor();
});
</script>
<div
data-slot="ai-persona"
data-state={personaState}
data-variant={variant}
class={cn("size-16 shrink-0", className)}
>
<canvas bind:this={canvas} class="size-full"></canvas>
</div>
src/lib/registry/ai/persona/sources.ts
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
export type PersonaVariant =
| "command"
| "glint"
| "halo"
| "mana"
| "obsidian"
| "opal";
interface PersonaSource {
dynamicColor: boolean;
hasModel: boolean;
source: string;
}
/** Rive artwork per variant. The state machine is always `default`. */
export const personaSources: Record<PersonaVariant, PersonaSource> = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
},
};
src/lib/registry/ai/persona/index.ts
import Persona from "./persona.svelte";
export type { PersonaState, PersonaVariant } from "./sources.js";
export { Persona };
Prop Type Default
state idle | listening | thinking | speaking | asleep idle
variant obsidian | mana | opal | halo | glint | command obsidian
className size and layout of the canvas size-16
onLoad, onLoadError, onReady, onPlay, onPause, onStop Rive callbacks
  • Dynamic-color variants follow light and dark, including a scoped .dark subtree (not only <html>).
  • Deviation from the board: the board shows tinted static orbs in --brand / --chart-*; the Rive files are fixed artwork, so only the monochrome variants recolor.
  • Vue and Svelte use @rive-app/webgl2 directly with the same artwork and states; callbacks are emits in Vue (load, loadError, ready, play, pause, stop) and on* props in Svelte.