Persona
Animated agent persona (Rive) reacting to idle, listening, thinking, speaking and asleep states.
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.
import { Persona, type PersonaState } from "@/components/ai/persona";import { useState } from "react";
const states: PersonaState[] = [ "idle", "listening", "thinking", "speaking", "asleep",];
export default function Demo() { const [state, setState] = useState<PersonaState>("idle"); return ( <div className="flex flex-col items-center gap-5"> <Persona className="size-24" state={state} variant="obsidian" /> <div className="flex flex-wrap justify-center gap-1.5"> {states.map((s) => ( <button className="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" data-active={state === s} key={s} onClick={() => setState(s)} type="button" > {s} </button> ))} </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, type PersonaState } from "@/components/ai/persona";import { ref } from "vue";
const states: PersonaState[] = ["idle", "listening", "thinking", "speaking", "asleep"];const state = ref<PersonaState>("idle");</script>
<template> <div class="flex flex-col items-center gap-5"> <Persona class="size-24" :state="state" variant="obsidian" /> <div class="flex flex-wrap justify-center gap-1.5"> <button v-for="s in states" :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" :data-active="state === s" @click="state = s" > {{ 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, type PersonaState } from "$lib/components/ai/persona";
const states: PersonaState[] = ["idle", "listening", "thinking", "speaking", "asleep"]; let current = $state<PersonaState>("idle");</script>
<div class="flex flex-col items-center gap-5"> <Persona class="size-24" state={current} 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" data-active={current === s} onclick={() => (current = s)} > {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>The Rive artwork is fetched from the network at runtime, so the canvas stays empty while offline or until the file has loaded.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-personapnpm dlx shadcn@latest add @edmi-ui/ai-personayarn dlx shadcn@latest add @edmi-ui/ai-personabunx shadcn@latest add @edmi-ui/ai-personaPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @rive-app/react-webgl2Terminal window pnpm add @rive-app/react-webgl2Terminal window yarn add @rive-app/react-webgl2Terminal window bun add @rive-app/react-webgl2Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-personapnpm dlx shadcn-vue@latest add @edmi-ui/ai-personayarn dlx shadcn-vue@latest add @edmi-ui/ai-personabunx shadcn-vue@latest add @edmi-ui/ai-personaPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @rive-app/webgl2 @vueuse/coreTerminal window pnpm add @rive-app/webgl2 @vueuse/coreTerminal window yarn add @rive-app/webgl2 @vueuse/coreTerminal window bun add @rive-app/webgl2 @vueuse/coreCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-persona.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Copy the code from Component source into your project and update the import paths to match your aliases.
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>Component source
Section titled “Component source”"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 visualsconst 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";<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 = nulllet mql: MediaQueryList | null = nullconst 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 = nullfunction 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>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", },};export { default as Persona } from "./Persona.vue";export type { PersonaState, PersonaVariant } from "./sources";<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>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", },};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 |
✦ Edmi additions
Section titled “✦ Edmi additions”- Dynamic-color variants follow light and dark, including a scoped
.darksubtree (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.
Framework notes
Section titled “Framework notes”- Vue and Svelte use
@rive-app/webgl2directly with the same artwork and states; callbacks are emits in Vue (load,loadError,ready,play,pause,stop) andon*props in Svelte.