All patterns

Tool Call Badge

A badge announces the tool an agent is calling, turns while it runs, then settles into the result count.

aiminimalfuturisticautomatic · finite · intermediate · ~2.2s
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.

314 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Tool Call Badge
 *
 * An agent announcing the tool it is reaching for: the badge slides in,
 * a ring turns while the call is in flight, then the ring gives way to a
 * tick and the result count.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `tool`, `argument`, `result`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ToolCallBadgeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name of the tool being invoked. */
  tool?: string;
  /** What the tool was called with. */
  argument?: string;
  /** Summary shown once the call returns. */
  result?: string;
  /** How long the call is shown in flight, in ms. */
  runningMs?: number;
  /** Accent for the running ring and the result chip. */
  color?: string;
};

type VariantConfig = {
  /** px the badge travels up as it lands. */
  riseY: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds per turn of the running ring. */
  spinSeconds: number;
  /** Crossfade between the ring and the tick. */
  swapSeconds: number;
  /** How the badge resizes when the result chip arrives. */
  layoutSpring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. This
// badge can appear four times in one answer; anything that overshoots
// twice would turn a normal agent turn into a bouncing queue.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.06 entry, ζ ≈ 1.04 resize — no overshoot anywhere. For traces
  // where several tools run in a row.
  subtle: {
    riseY: 3,
    spring: { type: "spring", stiffness: 470, damping: 46 },
    spinSeconds: 1.03,
    swapSeconds: 0.13,
    layoutSpring: { type: "spring", stiffness: 620, damping: 52 },
  },
  // ζ ≈ 0.93 entry, ζ ≈ 0.89 resize. The all-purpose setting.
  default: {
    riseY: 7,
    spring: { type: "spring", stiffness: 420, damping: 38 },
    spinSeconds: 0.85,
    swapSeconds: 0.18,
    layoutSpring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // ζ ≈ 0.82 entry — one soft settle and a quicker ring, for a single
  // prominent call rather than a list of them.
  playful: {
    riseY: 12,
    spring: { type: "spring", stiffness: 370, damping: 32 },
    spinSeconds: 0.67,
    swapSeconds: 0.23,
    layoutSpring: { type: "spring", stiffness: 380, damping: 34 },
  },
};

const DONE = "#34D399";

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  that is correctly toned in either theme. The completion green stays
 *  literal: it is a state color, not a surface. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ToolCallBadge({
  variant = "default",
  tool = "search_documents",
  argument = "quarterly reports",
  result = "12 matches",
  runningMs = 1500,
  color = "#7C7CF0",
}: ToolCallBadgeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [done, setDone] = useState(false);

  // One timer, scheduled from the current state and cleaned up on
  // unmount: nothing to cancel by hand, and a badge that unmounts
  // mid-call leaves no pending work behind.
  useEffect(() => {
    if (done) return;
    const timer = setTimeout(() => setDone(true), runningMs);
    return () => clearTimeout(timer);
  }, [done, runningMs]);

  // Reduced motion: the badge fades in and the ring holds still. The
  // running/finished states still change, because that is information,
  // not decoration.
  const entry = reduceMotion
    ? { hidden: { opacity: 0 }, shown: { opacity: 1 } }
    : { hidden: { opacity: 0, y: cfg.riseY }, shown: { opacity: 1, y: 0 } };

  return (
    <motion.div
      // The badge widens when the result chip arrives. `layout` animates
      // that width; every text child below is `layout="position"` so the
      // glyphs are scale-corrected and never stretch during the resize.
      layout={!reduceMotion}
      variants={entry}
      initial="hidden"
      animate="shown"
      transition={{
        layout: cfg.layoutSpring,
        y: cfg.spring,
        // Opacity on its own quick curve; springing a fade looks muddy.
        opacity: { duration: 0.18, ease: "easeOut" },
      }}
      role="status"
      aria-live="polite"
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 8,
        padding: "6px 10px 6px 8px",
        borderRadius: 999,
        background: tone(6),
        border: `1px solid ${done ? tone(13) : tone(11)}`,
        transition: "border-color 220ms ease-out",
        fontSize: 12.5,
        lineHeight: 1,
        whiteSpace: "nowrap",
      }}
    >
      <motion.span
        layout={reduceMotion ? false : "position"}
        aria-hidden
        style={{
          display: "inline-flex",
          width: 15,
          height: 15,
          alignItems: "center",
          justifyContent: "center",
          opacity: 0.55,
        }}
      >
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none">
          <circle
            cx="6.2"
            cy="6.2"
            r="4"
            stroke="currentColor"
            strokeWidth="1.5"
          />
          <path
            d="M9.3 9.3 12 12"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
          />
        </svg>
      </motion.span>

      <motion.span
        layout={reduceMotion ? false : "position"}
        style={{
          fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
          fontSize: 12,
          fontWeight: 600,
          opacity: 0.86,
        }}
      >
        {tool}
      </motion.span>

      <motion.span
        layout={reduceMotion ? false : "position"}
        style={{
          display: "inline-block",
          maxWidth: 128,
          overflow: "hidden",
          textOverflow: "ellipsis",
          opacity: 0.45,
        }}
      >
        {argument}
      </motion.span>

      {/* Running ring and finished tick occupy the same fixed slot, so
          the swap is a pure crossfade and never nudges the text. */}
      <motion.span
        layout={reduceMotion ? false : "position"}
        aria-hidden
        style={{
          position: "relative",
          width: 14,
          height: 14,
          flex: "0 0 auto",
        }}
      >
        <AnimatePresence initial={false} mode="wait">
          {done ? (
            <motion.svg
              key="done"
              width="14"
              height="14"
              viewBox="0 0 14 14"
              fill="none"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
              style={{ position: "absolute", inset: 0 }}
            >
              <circle cx="7" cy="7" r="6.4" fill={DONE} opacity="0.18" />
              <motion.path
                d="M4.2 7.2 6.2 9.2 9.9 4.9"
                stroke={DONE}
                strokeWidth="1.7"
                strokeLinecap="round"
                strokeLinejoin="round"
                initial={reduceMotion ? { pathLength: 1 } : { pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{
                  duration: reduceMotion ? 0 : 0.24,
                  delay: reduceMotion ? 0 : cfg.swapSeconds * 0.5,
                  ease: [0.22, 1, 0.36, 1],
                }}
              />
            </motion.svg>
          ) : (
            <motion.span
              key="running"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
              style={{ position: "absolute", inset: 0 }}
            >
              {/* Reduced motion: the ring is drawn but does not turn —
                  the state is carried by the shape and by aria-live. */}
              <motion.svg
                width="14"
                height="14"
                viewBox="0 0 14 14"
                fill="none"
                animate={reduceMotion ? undefined : { rotate: 360 }}
                transition={
                  reduceMotion
                    ? undefined
                    : {
                        duration: cfg.spinSeconds,
                        repeat: Infinity,
                        ease: "linear",
                      }
                }
              >
                <circle
                  cx="7"
                  cy="7"
                  r="5.4"
                  stroke="currentColor"
                  strokeOpacity="0.2"
                  strokeWidth="1.7"
                />
                <path
                  d="M7 1.6a5.4 5.4 0 0 1 5.4 5.4"
                  stroke={color}
                  strokeWidth="1.7"
                  strokeLinecap="round"
                />
              </motion.svg>
            </motion.span>
          )}
        </AnimatePresence>
      </motion.span>

      <AnimatePresence initial={false}>
        {done && (
          <motion.span
            key="result"
            layout={reduceMotion ? false : "position"}
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -4 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.22, ease: "easeOut" }}
            style={{
              padding: "3px 7px",
              borderRadius: 999,
              background: tone(9),
              fontSize: 11.5,
              fontWeight: 600,
              opacity: 0.8,
              fontVariantNumeric: "tabular-nums",
            }}
          >
            {result}
          </motion.span>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

About this pattern

Agents spend most of a turn doing something other than writing, and a silent gap reads as a hang. This badge names the call as it starts, holds a turning ring for as long as the work takes, then swaps the ring for a tick and widens to admit the count that came back. The widening is a layout animation with position-only children, so the glyphs are scale-corrected and no text stretches while the pill resizes — the one detail that separates this from a jittery chip.

Agent tool invocationFunction call statusRetrieval step in a turnBackground API call

Where it shows up

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

  • 10:15
    Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    Tool invocations surface inline in the turn with a running state that resolves to a summary.

Related patterns