AI Avatar Idle
The assistant mark keeps a slow breath with a halo swelling a half-beat behind it, so the panel reads as awake.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · AI Avatar Idle
*
* The assistant mark at rest: a slow breath in the tile, a halo that
* swells a half-beat behind it, and the glyph turning a few degrees —
* enough for the surface to read as awake rather than frozen.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `size`, `color`, `label`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AiAvatarIdleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Tile edge in px. */
size?: number;
/** Mark color. Semantic identity, so it stays literal. */
color?: string;
/** Accessible name for the mark. */
label?: string;
};
type VariantConfig = {
/** Peak of the breath. Kept under 1.06 — this thing never stops. */
breathScale: number;
/** One full inhale and exhale, in seconds. */
cycleSeconds: number;
/** Halo swell. 0 turns the halo off entirely. */
haloScale: number;
/** Degrees the glyph turns each way. */
glyphTurn: number;
};
// An idle loop is on screen for minutes at a time, so its whole job is to
// stay under the reader's attention: slow cycles, tiny amplitudes, and an
// offset between the tile and the halo so the two never pulse in lockstep
// (synchronised pulsing is what makes an idle state read as a heartbeat
// monitor). No springs — a breath that overshoots is a twitch.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Breath only. For an avatar repeated down a message list.
subtle: { breathScale: 1.02, cycleSeconds: 4, haloScale: 0, glyphTurn: 0 },
// Breath and halo. The all-purpose setting.
default: {
breathScale: 1.03,
cycleSeconds: 3.4,
haloScale: 1.14,
glyphTurn: 4,
},
// A quicker cycle and a slow turn on the glyph, for a single hero mark
// on an empty assistant screen.
playful: {
breathScale: 1.05,
cycleSeconds: 2.8,
haloScale: 1.2,
glyphTurn: 7,
},
};
export default function AiAvatarIdle({
variant = "default",
size = 44,
color = "#7C7CF0",
label = "Assistant",
}: AiAvatarIdleProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const radius = Math.round(size * 0.34);
const tile = (
<span
style={{
position: "absolute",
inset: 0,
borderRadius: radius,
background: `linear-gradient(150deg, ${color}F2, ${color}B3)`,
boxShadow: `0 6px 18px ${color}33`,
}}
/>
);
const glyph = (
<svg
width={Math.round(size * 0.46)}
height={Math.round(size * 0.46)}
viewBox="0 0 16 16"
fill="none"
aria-hidden
style={{ position: "relative" }}
>
<path
d="M8 1.4 9.6 5.7 13.9 7.3 9.6 8.9 8 13.2 6.4 8.9 2.1 7.3 6.4 5.7z"
fill="#FFFFFF"
/>
<circle cx="12.6" cy="3.2" r="1.25" fill="#FFFFFF" opacity="0.75" />
</svg>
);
// Reduced motion: the mark at the middle of its breath. Presence is
// carried by the mark being there, not by it moving.
if (reduceMotion) {
return (
<span
role="img"
aria-label={label}
style={{
position: "relative",
display: "inline-grid",
placeItems: "center",
width: size,
height: size,
}}
>
{tile}
{glyph}
</span>
);
}
return (
<span
role="img"
aria-label={label}
style={{
position: "relative",
display: "inline-grid",
placeItems: "center",
width: size,
height: size,
}}
>
{cfg.haloScale > 0 && (
<motion.span
aria-hidden
initial={{ scale: 1, opacity: 0.3 }}
animate={{
scale: [1, cfg.haloScale, 1],
opacity: [0.22, 0.42, 0.22],
}}
transition={{
duration: cfg.cycleSeconds,
repeat: Infinity,
ease: "easeInOut",
// A half-beat behind the tile, so the two never pulse together.
delay: cfg.cycleSeconds * 0.18,
}}
style={{
position: "absolute",
inset: -Math.round(size * 0.14),
borderRadius: radius + Math.round(size * 0.14),
background: `radial-gradient(circle, ${color}59 0%, ${color}00 70%)`,
}}
/>
)}
<motion.span
aria-hidden
animate={{ scale: [1, cfg.breathScale, 1] }}
transition={{
duration: cfg.cycleSeconds,
repeat: Infinity,
ease: "easeInOut",
}}
style={{ position: "absolute", inset: 0 }}
>
{tile}
</motion.span>
<motion.span
aria-hidden
animate={
cfg.glyphTurn > 0
? { rotate: [0, cfg.glyphTurn, 0, -cfg.glyphTurn, 0] }
: { opacity: [0.92, 1, 0.92] }
}
transition={{
duration: cfg.cycleSeconds * 2,
repeat: Infinity,
ease: "easeInOut",
}}
style={{ display: "grid", placeItems: "center" }}
>
{glyph}
</motion.span>
</span>
);
}About this pattern
The state an assistant spends most of its life in: nothing running, nobody typing, and a surface that still has to look switched on rather than crashed. A very slow scale cycle in the tile, a halo offset from it by a fraction of a beat, and a few degrees of turn in the glyph — amplitudes small enough that the eye registers presence without being pulled back to it. The offset matters: two elements pulsing in lockstep read as a heartbeat monitor, which is a different and much more urgent message than idle.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
An ambient mark that keeps a slow cycle between requests so the device reads as attentive.
Related patterns
- Regenerate SwapThe old answer dissolves upward while the new one rises into the space it left.
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.