OverlaysNEW
Persona
#ai#avatar#animated#stateful
obsidian
Variants
approximateReference recreation
Implemented as a Framer Motion recreation. The reference calls for Rive/WebGL2 behavior, but this repo does not contain .riv assets or a Rive runtime integration yet.
Installation
Use the registry command to add the component source, and install any package dependencies if needed.
Infinity Registry
Install the component source into your project with the shadcn CLI.
npx shadcn@latest add https://infinityui-pearl.vercel.app/r/personaPackage Dependencies
Install the npm packages used by this component source.
npm install clsx framer-motion tailwind-mergeComponent Code
Copy and paste this code into your component file.
tsx
"use client";
import { useEffect, useRef, type ComponentPropsWithoutRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
export type PersonaVariant =
| "obsidian"
| "mana"
| "opal"
| "halo"
| "glint"
| "command";
type PersonaTheme = {
frame: string;
aura: string;
shell: string;
ring: string;
core: string;
glow: string;
dot: string;
accent: string;
shape: string;
};
const PERSONA_THEMES: Record<PersonaVariant, PersonaTheme> = {
obsidian: {
frame: "border-white/10 bg-[radial-gradient(circle_at_top,rgba(129,140,248,0.16),transparent_38%),linear-gradient(180deg,rgba(2,6,23,0.96),rgba(15,23,42,0.98))]",
aura: "bg-[radial-gradient(circle,rgba(99,102,241,0.45),rgba(14,165,233,0.16)_45%,transparent_72%)]",
shell: "border-white/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))]",
ring: "border-indigo-300/35",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(14,165,233,0.92),rgba(99,102,241,0.95),rgba(168,85,247,0.88),rgba(14,165,233,0.92))]",
glow: "bg-[radial-gradient(circle,rgba(191,219,254,0.95),rgba(191,219,254,0.18)_50%,transparent_75%)]",
dot: "bg-indigo-200/90",
accent: "bg-indigo-400/70",
shape: "rounded-full",
},
mana: {
frame: "border-emerald-300/12 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_34%),linear-gradient(180deg,rgba(3,7,18,0.96),rgba(6,78,59,0.18))]",
aura: "bg-[radial-gradient(circle,rgba(16,185,129,0.48),rgba(45,212,191,0.22)_45%,transparent_72%)]",
shell: "border-emerald-200/15 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(16,185,129,0.04))]",
ring: "border-emerald-300/35",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(16,185,129,0.95),rgba(45,212,191,0.92),rgba(34,197,94,0.9),rgba(16,185,129,0.95))]",
glow: "bg-[radial-gradient(circle,rgba(209,250,229,0.95),rgba(209,250,229,0.2)_52%,transparent_78%)]",
dot: "bg-emerald-100/90",
accent: "bg-emerald-300/70",
shape: "rounded-full",
},
opal: {
frame: "border-white/12 bg-[radial-gradient(circle_at_top,rgba(244,114,182,0.18),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.95),rgba(30,41,59,0.92))]",
aura: "bg-[radial-gradient(circle,rgba(244,114,182,0.42),rgba(125,211,252,0.22)_40%,rgba(196,181,253,0.16)_55%,transparent_74%)]",
shell: "border-white/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(244,114,182,0.04))]",
ring: "border-rose-200/30",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(244,114,182,0.92),rgba(125,211,252,0.95),rgba(196,181,253,0.95),rgba(244,114,182,0.92))]",
glow: "bg-[radial-gradient(circle,rgba(255,255,255,0.98),rgba(255,255,255,0.22)_45%,transparent_72%)]",
dot: "bg-white/95",
accent: "bg-rose-200/70",
shape: "rounded-full",
},
halo: {
frame: "border-amber-200/14 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.18),transparent_32%),linear-gradient(180deg,rgba(12,10,9,0.96),rgba(30,27,24,0.98))]",
aura: "bg-[radial-gradient(circle,rgba(251,191,36,0.52),rgba(245,158,11,0.2)_42%,transparent_72%)]",
shell: "border-amber-100/15 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(251,191,36,0.04))]",
ring: "border-amber-200/35",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(251,191,36,0.96),rgba(245,158,11,0.92),rgba(253,224,71,0.88),rgba(251,191,36,0.96))]",
glow: "bg-[radial-gradient(circle,rgba(254,243,199,0.98),rgba(254,243,199,0.18)_48%,transparent_74%)]",
dot: "bg-amber-50/95",
accent: "bg-amber-300/75",
shape: "rounded-full",
},
glint: {
frame: "border-sky-100/12 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.16),transparent_34%),linear-gradient(180deg,rgba(8,15,30,0.96),rgba(8,47,73,0.22))]",
aura: "bg-[radial-gradient(circle,rgba(125,211,252,0.46),rgba(165,243,252,0.2)_45%,transparent_72%)]",
shell: "border-sky-100/14 bg-[linear-gradient(180deg,rgba(255,255,255,0.07),rgba(125,211,252,0.04))]",
ring: "border-sky-200/35",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(125,211,252,0.98),rgba(224,242,254,0.96),rgba(103,232,249,0.92),rgba(125,211,252,0.98))]",
glow: "bg-[radial-gradient(circle,rgba(255,255,255,0.98),rgba(224,242,254,0.18)_48%,transparent_74%)]",
dot: "bg-white/95",
accent: "bg-sky-200/75",
shape: "rounded-full",
},
command: {
frame: "border-lime-300/12 bg-[radial-gradient(circle_at_top,rgba(74,222,128,0.16),transparent_34%),linear-gradient(180deg,rgba(2,12,10,0.98),rgba(5,46,22,0.78))]",
aura: "bg-[radial-gradient(circle,rgba(74,222,128,0.42),rgba(34,197,94,0.18)_45%,transparent_72%)]",
shell: "border-lime-200/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(74,222,128,0.04))]",
ring: "border-lime-300/35",
core: "bg-[conic-gradient(from_180deg_at_50%_50%,rgba(34,197,94,0.96),rgba(74,222,128,0.92),rgba(132,204,22,0.88),rgba(34,197,94,0.96))]",
glow: "bg-[radial-gradient(circle,rgba(220,252,231,0.96),rgba(220,252,231,0.16)_45%,transparent_74%)]",
dot: "bg-lime-100/95",
accent: "bg-lime-300/70",
shape: "rounded-[28%]",
},
};
const STATE_COPY: Record<PersonaState, string> = {
idle: "Idle",
listening: "Listening",
thinking: "Thinking",
speaking: "Speaking",
asleep: "Asleep",
};
export interface PersonaProps extends ComponentPropsWithoutRef<"div"> {
state?: PersonaState;
variant?: PersonaVariant;
onLoad?: () => void;
onLoadError?: (error: Error) => void;
onReady?: () => void;
onPause?: () => void;
onPlay?: () => void;
onStop?: () => void;
}
export function Persona({
state = "idle",
variant = "obsidian",
className,
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
...props
}: PersonaProps) {
const theme = PERSONA_THEMES[variant];
const readyRef = useRef(false);
useEffect(() => {
try {
onLoad?.();
} catch (error) {
onLoadError?.(
error instanceof Error
? error
: new Error("Persona failed during load.")
);
}
const frame = requestAnimationFrame(() => {
readyRef.current = true;
onReady?.();
if (state === "asleep") {
onPause?.();
} else {
onPlay?.();
}
});
return () => {
cancelAnimationFrame(frame);
onStop?.();
};
}, []);
useEffect(() => {
if (!readyRef.current) {
return;
}
if (state === "asleep") {
onPause?.();
} else {
onPlay?.();
}
}, [state, onPause, onPlay]);
const isListening = state === "listening";
const isThinking = state === "thinking";
const isSpeaking = state === "speaking";
const isAsleep = state === "asleep";
return (
<div
aria-label={`Persona ${STATE_COPY[state]} state`}
className={cn(
"relative aspect-square overflow-hidden rounded-[34px] border p-3 shadow-[0_24px_80px_rgba(0,0,0,0.35)]",
theme.frame,
className
)}
{...props}
>
<div className="absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)] [background-size:24px_24px]" />
<motion.div
className={cn(
"absolute inset-[16%] blur-3xl",
theme.aura,
theme.shape
)}
animate={{
scale: isSpeaking
? [1, 1.18, 0.98]
: isListening
? [0.96, 1.08, 1]
: isThinking
? [1, 1.06, 1]
: isAsleep
? [0.88, 0.92, 0.88]
: [0.98, 1.04, 0.98],
opacity: isAsleep ? [0.2, 0.28, 0.2] : [0.48, 0.72, 0.48],
}}
transition={{
duration: isSpeaking ? 1.05 : isListening ? 1.8 : 3.4,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{isListening && (
<>
{[0, 0.35].map((delay, index) => (
<motion.div
key={index}
className={cn(
"absolute inset-[10%] rounded-full border",
theme.ring
)}
animate={{ scale: [0.88, 1.18], opacity: [0.55, 0] }}
transition={{
duration: 1.8,
repeat: Infinity,
ease: "easeOut",
delay,
}}
/>
))}
</>
)}
<motion.div
className="absolute inset-[6%]"
animate={{ rotate: isThinking ? 360 : isSpeaking ? 180 : 0 }}
transition={{
duration: isThinking ? 6 : isSpeaking ? 10 : 14,
repeat: Infinity,
ease: "linear",
}}
>
{[0, 120, 240].map((deg, index) => (
<motion.div
key={deg}
className="absolute left-1/2 top-1/2"
style={{
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-42%)`,
}}
animate={{
scale: isThinking
? [0.9, 1.15, 0.9]
: isListening
? [0.85, 1, 0.85]
: isAsleep
? [0.5, 0.7, 0.5]
: [0.75, 0.92, 0.75],
opacity: isAsleep ? 0.2 : 0.78 - index * 0.12,
}}
transition={{
duration: 1.4 + index * 0.2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<div
className={cn(
"size-2 rounded-full shadow-[0_0_20px_rgba(255,255,255,0.28)]",
theme.dot
)}
/>
</motion.div>
))}
</motion.div>
<div
className={cn(
"absolute inset-[13%] border backdrop-blur-md",
theme.shell,
theme.shape
)}
/>
<motion.div
className={cn(
"absolute inset-[20%] border",
theme.ring,
theme.shape
)}
animate={{
rotate: isThinking ? -360 : 0,
scale: isSpeaking
? [0.98, 1.04, 0.98]
: isListening
? [1, 1.03, 1]
: isAsleep
? [0.98, 0.99, 0.98]
: [1, 1.02, 1],
}}
transition={{
rotate: {
duration: 10,
repeat: Infinity,
ease: "linear",
},
scale: {
duration: isSpeaking ? 1.1 : 2.8,
repeat: Infinity,
ease: "easeInOut",
},
}}
/>
<motion.div
className={cn(
"absolute inset-[28%] overflow-hidden shadow-[0_18px_50px_rgba(0,0,0,0.35)]",
theme.core,
theme.shape
)}
animate={{
scale: isSpeaking
? [0.96, 1.08, 0.98]
: isListening
? [0.96, 1.04, 1]
: isThinking
? [1, 1.03, 0.99]
: isAsleep
? [0.88, 0.92, 0.88]
: [0.98, 1.02, 0.98],
rotate: isThinking ? 360 : 0,
}}
transition={{
scale: {
duration: isSpeaking ? 1 : isListening ? 1.6 : 3.2,
repeat: Infinity,
ease: "easeInOut",
},
rotate: {
duration: 9,
repeat: Infinity,
ease: "linear",
},
}}
>
<motion.div
className={cn(
"absolute inset-[18%] blur-2xl mix-blend-screen",
theme.glow,
theme.shape
)}
animate={{
opacity: isAsleep ? [0.1, 0.18, 0.1] : [0.4, 0.8, 0.4],
scale: isSpeaking ? [0.7, 1.08, 0.78] : [0.74, 0.98, 0.74],
}}
transition={{
duration: isSpeaking ? 0.95 : 2.6,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute inset-0 opacity-40"
style={{
backgroundImage:
"radial-gradient(circle at 30% 25%, rgba(255,255,255,0.42), transparent 28%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.16), transparent 34%)",
}}
animate={{
rotate: isThinking ? -360 : 0,
opacity: isAsleep ? 0.16 : 0.4,
}}
transition={{
duration: 12,
repeat: Infinity,
ease: "linear",
}}
/>
</motion.div>
{variant === "glint" && (
<motion.div
className="absolute inset-[26%] flex items-center justify-center"
animate={{
rotate: [0, 90, 180, 270, 360],
opacity: [0.35, 0.7, 0.35],
}}
transition={{ duration: 6, repeat: Infinity, ease: "linear" }}
>
<div className="relative size-full">
<div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-white/18" />
<div className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-white/18" />
</div>
</motion.div>
)}
{isSpeaking && (
<div className="absolute inset-x-0 bottom-[16%] flex items-end justify-center gap-1">
{[0, 1, 2, 3, 4].map((index) => (
<motion.span
key={index}
className={cn(
"block w-1.5 rounded-full",
theme.accent
)}
animate={{
height: [10, 24 + (index % 3) * 8, 12],
opacity: [0.5, 1, 0.6],
}}
transition={{
duration: 0.9 + index * 0.08,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.08,
}}
/>
))}
</div>
)}
{isAsleep && (
<motion.div
className="absolute inset-[34%] flex items-center justify-center"
animate={{ opacity: [0.3, 0.7, 0.3], y: [0, 2, 0] }}
transition={{ duration: 3.6, repeat: Infinity, ease: "easeInOut" }}
>
<div className="flex items-center gap-3">
<div className="h-1 w-5 rounded-full bg-white/60" />
<div className="h-1 w-5 rounded-full bg-white/60" />
</div>
</motion.div>
)}
<div className="absolute inset-x-0 bottom-3 flex justify-center">
<span className="rounded-full border border-white/8 bg-black/25 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.28em] text-zinc-400">
{variant}
</span>
</div>
</div>
);
}
export default Persona;