All patterns

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.

aifriendlycalmautomatic · looping · starter · ~3.4s
Variant

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.

189 lines · react + motion only
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.

Assistant idle stateChat header presenceEmpty assistant panelVoice agent at rest

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    Working through the document…
    Ask a follow-up
    AI assistant

    An ambient mark that keeps a slow cycle between requests so the device reads as attentive.

Related patterns