A shimmering assistant-is-thinking label with optional elapsed seconds that blur-crossfades in and out of the response.
npx shadcn@latest add @paragon/thinking-indicatorAlso installs: shimmer-text
"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>
);
}