Skip to content
Motion
FreeIn reviewv1.0.0

Thinking Indicator

An honest working line for AI chats: a small orb, a status that advances in stages, and a spectrum comet.

npx shadcn@latest add @vibebox/thinking-indicator

Controls

<ThinkingIndicator />

About

The moment between a question and the first word of an answer. Instead of three bouncing dots, a small orb with three circling droplets sits beside a status line that names what the assistant is doing ("Checking the packages"), then moves through honest stages ("Drafting the answer", "Writing it up") while the wait continues. A hairline below carries a travelling spectrum comet that says "working" without pretending to know a percentage. Ported from the VibeBox assistant, where the avatar is the Liquid Orb; here the default avatar is a CSS orb and any avatar can be passed in.

Use it when

  • An AI or server reply takes more than a moment.
  • The server can say what it is doing (searching, checking, comparing).

Skip it when

  • The wait is under about 300 ms; show nothing.
  • Real progress is known (an upload); show a real progress bar.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/thinking-indicator
  3. 3Use it:
    import { ThinkingIndicator } from "@/components/vibebox/thinking-indicator";
    
    {status === "thinking" && <ThinkingIndicator label={serverStatus ?? "Thinking"} />}

Code

2 files
components/vibebox/thinking-indicator/index.tsx
/**
 * VibeBox Motion · Thinking Indicator
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { ThinkingIndicator, ThinkingOrb, type ThinkingIndicatorProps } from "./thinking-indicator.client";
components/vibebox/thinking-indicator/thinking-indicator.client.tsx
/**
 * VibeBox Motion · Thinking Indicator
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { AnimatePresence, LazyMotion, MotionConfig, domAnimation, m } from "motion/react";
import { useEffect, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { duration, ease } from "@/lib/vibebox/tokens/motion";

/* While an assistant prepares a reply: an avatar (a small CSS orb by default) and a shimmering
   status line that advances through honest stages, over a hairline with a spectrum comet. The
   comet says "working", never a fake percentage. */

const CSS = `
@keyframes vb-ti-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@keyframes vb-ti-comet { from { transform: translateX(-100%); } to { transform: translateX(260%); } }
@keyframes vb-ti-orbit { to { transform: rotate(360deg); } }
@keyframes vb-ti-breathe { 50% { transform: scale(0.92); } }
[data-vb-ti-label] {
  color: transparent;
  background: linear-gradient(90deg, var(--subtle-foreground) 0%, var(--subtle-foreground) 35%, var(--foreground) 50%, var(--subtle-foreground) 65%, var(--subtle-foreground) 100%);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
}
[data-vb-ti-comet]::after {
  position: absolute; inset: 0; width: 40%; content: ""; border-radius: 2px; background: var(--vb-gradient-spectrum);
}
@media (prefers-reduced-motion: no-preference) {
  [data-vb-ti-label] { animation: vb-ti-shimmer 1.8s linear infinite; }
  [data-vb-ti-comet]::after { animation: vb-ti-comet 1.4s var(--ease-expo-in-out, cubic-bezier(0.65, 0, 0.35, 1)) infinite; }
  [data-vb-ti-orbit] { animation: vb-ti-orbit 2.4s linear infinite; }
  [data-vb-ti-core] { animation: vb-ti-breathe 1.8s ease-in-out infinite; }
}
@media (prefers-reduced-motion: reduce) {
  [data-vb-ti-label] { color: var(--muted-foreground); background: none; }
  [data-vb-ti-comet]::after { width: 100%; opacity: 0.5; }
}
`;

/** The default avatar: a small spectrum orb with three droplets circling it, in CSS. */
export function ThinkingOrb({ size = 44 }: { size?: number }) {
  return (
    <span aria-hidden="true" className="relative inline-block shrink-0" style={{ width: size, height: size }}>
      <span
        data-vb-ti-core=""
        className="absolute inset-[18%] rounded-full"
        style={{
          background:
            "radial-gradient(circle at 32% 30%, color-mix(in oklab, white 70%, transparent) 0%, transparent 34%), radial-gradient(circle at 50% 55%, var(--spectrum-3), var(--spectrum-1) 70%)",
          boxShadow: "0 0 24px color-mix(in oklab, var(--spectrum-3) 45%, transparent)",
        }}
      />
      <span data-vb-ti-orbit="" className="absolute inset-0">
        {[1, 4, 6].map((stop, index) => (
          <span
            key={stop}
            className="absolute size-[18%] rounded-full"
            style={{
              top: `${50 + 41 * Math.sin((index * 2 * Math.PI) / 3) - 9}%`,
              left: `${50 + 41 * Math.cos((index * 2 * Math.PI) / 3) - 9}%`,
              background: `var(--spectrum-${stop})`,
            }}
          />
        ))}
      </span>
    </span>
  );
}

const DEFAULT_STAGES = ["Drafting the answer", "Writing it up"] as const;
const DEFAULT_DELAYS = [1700, 4200] as const;

export type ThinkingIndicatorProps = {
  /** What the assistant is doing right now, for example "Checking the menu". */
  label: string;
  /** Lines that follow the label while the wait goes on. */
  stages?: readonly string[];
  /** Milliseconds before each stage appears, counted from the latest label. */
  stageDelays?: readonly number[];
  /** Replaces the default orb (for example the Liquid Orb). */
  avatar?: ReactNode;
  className?: string;
};

export function ThinkingIndicator({
  label,
  stages = DEFAULT_STAGES,
  stageDelays = DEFAULT_DELAYS,
  avatar,
  className,
}: ThinkingIndicatorProps) {
  const [stage, setStage] = useState(0);

  // Start over whenever the label changes (the assistant is looking at something new). Keyed on
  // the arrays' contents, so callers can pass inline arrays without resetting the timers.
  const stageKey = stages.join("\u0000");
  const delayKey = stageDelays.join(",");
  useEffect(() => {
    setStage(0);
    const delays = delayKey.split(",").map(Number);
    const timers = stageKey
      .split("\u0000")
      .map((_, index) => window.setTimeout(() => setStage(index + 1), delays[index] ?? 1700 * (index + 1)));
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [label, stageKey, delayKey]);

  const text = stage === 0 ? label : (stages[stage - 1] ?? label);

  return (
    <LazyMotion features={domAnimation} strict>
      <MotionConfig reducedMotion="user">
      <div data-vb="thinking-indicator" role="status" aria-live="polite" className={cn("flex min-h-14 items-center gap-3", className)}>
        <style href="vb-thinking-indicator" precedence="vb">
          {CSS}
        </style>
        {avatar ?? <ThinkingOrb />}
        <div className="grid min-w-0 gap-1.5">
          <AnimatePresence mode="wait" initial={false}>
            <m.span
              key={text}
              data-vb-ti-label=""
              className="font-mono text-caption whitespace-nowrap"
              initial={{ opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -4 }}
              transition={{ duration: duration.reveal * 0.7, ease: ease.out }}
            >
              {text}
            </m.span>
          </AnimatePresence>
          <span data-vb-ti-comet="" aria-hidden="true" className="relative h-0.5 w-33 overflow-hidden rounded-full bg-foreground/10" />
        </div>
      </div>
      </MotionConfig>
    </LazyMotion>
  );
}

Props

PropTypeDefaultDescription
label *string—What the assistant is doing now, e.g. "Checking the menu".
stagesreadonly string[]["Drafting the answer", "Writing it up"]Lines that follow the label while the wait goes on.
stageDelaysreadonly number[][1700, 4200]Milliseconds before each stage, counted from the latest label.
avatarReactNode—Replaces the default CSS orb.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
Not focusable; it has no actions.
Screen readers
role="status" with aria-live="polite": each stage is announced once; the orb and comet are hidden.
Touch
Nothing to touch.
Reduced motion
A still orb, the status in muted text, and a full, faint hairline.

Edge cases and limits

  • Long labels do not wrap; they are clipped by the container.
  • An empty stages list keeps the label until it changes.
  • A chat launcher with a teaser of suggested questions

    The front of an AI chat: a magnetic launcher with a question teaser, a window or phone sheet, and a quick menu.

    AI and chat · Motion

  • Chat reply cards showing an estimate and steps

    Typed cards for AI chat replies: links, steps, plans, estimates, projects and FAQs, rendered from data.

    AI and chat · Server

  • A glossy liquid orb in spectrum colours, rippling and changing mood

    A living liquid drop for an AI assistant: it listens, thinks and speaks, dents under touch and morphs between shapes.

    3D · WebGL2

↑ ↓ to move↵ to openesc to close