INFINITYUI
HomeComponentsDocsTemplates
Star

Getting Started

  • Introduction
  • Installation
  • CLI
  • Audit

Navigation

  • Spotlight Navbar
  • Glass DockNEW
  • Animated Tab Bar
  • Circle Menu
  • Magnet Tabs
  • Animated Sidebar
  • Apple Spotlight
  • Page TOC RailNEW

Text

  • Flip Text
  • Glitch Text
  • Liquid Text
  • Flip Fade Text
  • Mask Cursor Effect

Cards

  • Glow Border Card
  • Testimonials Card
  • Interactive Book
  • Trading CardsNEW
  • Hover Image
  • Chain of ThoughtNEW
  • Masonry Grid
  • Image Pile
  • Staggered Grid

Inputs

  • AI InputNEW
  • OTP Input
  • Leave Rating

Buttons

  • Social Flip Button
  • Creepy Button

Loaders

  • Jelly Loader
  • Rolling Ball Scroll
  • Glowing Scroll

Backgrounds

  • Light Lines
  • Perspective Grid
  • Liquid Ocean
  • Eagle Vision
  • Flow Scroll
  • Horizontal Scroll

Overlays

  • PersonaNEW
  • Infinite Moving Cards
  • Masked Avatars
  • Stacked Logos
  • Icon Wheel
  • Pixelated CarouselNEW
  • Pixelated Image Trail
  • Flip Scroll
  • Interactive Folder
  • Animated Folder IconNEW
  • Stack Scroll
  • Rubik Cube
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/persona
Package Dependencies

Install the npm packages used by this component source.

npm install clsx framer-motion tailwind-merge

Component 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;