AI & Chat

Thinking Indicator

A shimmering assistant-is-thinking label with optional elapsed seconds that blur-crossfades in and out of the response.

Install

npx shadcn@latest add @paragon/thinking-indicator

Also installs: shimmer-text

thinking-indicator.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ShimmerText } from "@/registry/paragon/ui/shimmer-text";
import { cn } from "@/lib/utils";

/** Mirrors var(--ease-out) — motion transition arrays can't read CSS custom properties. */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface ThinkingIndicatorProps
  extends Omit<
    React.ComponentProps<"div">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  /** The shimmering status label. */
  label?: string;
  /** Show elapsed seconds since mount, tabular-nums. */
  showElapsed?: boolean;
  /** Renders the label without shimmer or enter/exit motion. */
  static?: boolean;
}

/**
 * "Assistant is thinking" — a shimmering label (masked gradient sweep via
 * ShimmerText) with optional elapsed seconds. The root is a motion element
 * with a blur-crossfade enter and exit, so wrapping it in AnimatePresence
 * swaps it against the arriving response cleanly.
 *
 * The elapsed counter derives from a start timestamp, not accumulated ticks,
 * so throttled background tabs stay accurate.
 */
export function ThinkingIndicator({
  label = "Thinking",
  showElapsed = true,
  static: isStatic = false,
  className,
  ...props
}: ThinkingIndicatorProps) {
  const reducedMotion = useReducedMotion();
  const [elapsed, setElapsed] = React.useState(0);

  React.useEffect(() => {
    if (!showElapsed) return;
    const start = Date.now();
    const id = setInterval(() => {
      setElapsed(Math.floor((Date.now() - start) / 1000));
    }, 1000);
    return () => clearInterval(id);
  }, [showElapsed]);

  const blur = isStatic || reducedMotion ? "blur(0px)" : "blur(4px)";

  return (
    <motion.div
      data-slot="thinking-indicator"
      role="status"
      initial={isStatic ? false : { opacity: 0, filter: blur }}
      animate={{ opacity: 1, filter: "blur(0px)" }}
      exit={
        isStatic
          ? undefined
          : {
              opacity: 0,
              filter: blur,
              transition: { duration: 0.15, ease: EASE_EXIT },
            }
      }
      transition={{ duration: 0.25, ease: EASE_OUT }}
      className={cn("inline-flex items-center gap-2 text-sm", className)}
      {...props}
    >
      <span className="sr-only">Assistant is thinking</span>
      <ShimmerText static={isStatic} aria-hidden className="font-medium">
        {label}
      </ShimmerText>
      {showElapsed && (
        <span
          aria-hidden
          className="text-xs text-muted-foreground tabular-nums"
        >
          {elapsed}s
        </span>
      )}
    </motion.div>
  );
}