Reasoning text
What the agent is doing while it reasons: phrases cycle with a cascade, swap or scramble behind an ASCII loader.
cascadeReading the brief
swapReading the brief
scrambleReading the brief
pnpm exec shadcn add @intelligo/ai-reasoning-textInside an app made by intelligo create, or any shadcn project that names the registry.
used by these pages
more in agent at work
Reasoning, Shimmer text, Agent activity, Approval card, File diff, Todo list, Tool approval, Tool result, Agent progress
Source
What shadcn add writes to components/ui/ai-reasoning-text.tsx. It is yours from then on.
"use client";
/*
* The line that says what the agent is doing while it reasons: phrases
* cycle with a cascade, a swap or a scramble, each shimmering, behind a
* terminal-style ASCII loader.
*/
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT, SPRING_SWAP } from "@showcase/components/ui/ai-motion";
import { shimmerStyle } from "@showcase/components/ui/ai-shimmer-text";
import { cn } from "@showcase/lib/utils";
/* ----------------------------------------------------------------------------
* AsciiLoader: the frame set a CLI agent cycles through.
* ------------------------------------------------------------------------- */
const ASCII_LINE = ["|", "/", "-", "\\"];
export interface AsciiLoaderProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** Glyphs shown in turn. */
frames?: string[];
/** Font size in px. */
size?: number;
/** Seconds per full cycle. */
speed?: number;
}
function AsciiLoader({
frames = ASCII_LINE,
size = 14,
speed = 0.8,
className,
style,
...props
}: AsciiLoaderProps) {
const reduced = useReducedMotion() ?? false;
const [frame, setFrame] = React.useState(0);
const count = frames.length;
React.useEffect(() => {
// Reduced motion slows the cycle rather than stopping it — it's a glyph
// swap, not on-screen movement.
const step = ((reduced ? speed * 2.5 : speed) / Math.max(1, count)) * 1000;
const id = window.setInterval(
() => setFrame((current) => (current + 1) % Math.max(1, count)),
step
);
return () => window.clearInterval(id);
}, [count, speed, reduced]);
return (
<span
data-slot="ascii-loader"
className={cn("font-mono leading-none tabular-nums", className)}
style={{ fontSize: size, lineHeight: 1, ...style }}
{...props}
>
{frames[frame % Math.max(1, count)]}
</span>
);
}
/* ----------------------------------------------------------------------------
* TextScramble: characters resolve into `text` left to right.
* ------------------------------------------------------------------------- */
const DEFAULT_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
export interface TextScrambleProps {
/** Final text revealed by the scramble animation. */
text: string;
/** Maximum animation duration in milliseconds. */
duration?: number;
/** Characters sampled while unresolved positions are scrambling. */
glyphs?: string;
className?: string;
style?: React.CSSProperties;
}
function TextScramble({
text,
duration,
glyphs = DEFAULT_GLYPHS,
className,
style,
}: TextScrambleProps) {
const reduced = useReducedMotion() ?? false;
const [display, setDisplay] = React.useState(text);
const mounted = React.useRef(false);
const still = reduced || !glyphs;
React.useEffect(() => {
// The first text lands as it is; only a change scrambles.
if (!mounted.current) {
mounted.current = true;
return;
}
if (still) return;
const characters = text.split("");
const startedAt = performance.now();
const animationDuration =
duration ?? Math.min(760, Math.max(420, characters.length * 32));
let frame = 0;
let lastUpdate = 0;
const animate = (now: number) => {
if (now - lastUpdate >= 40) {
lastUpdate = now;
const progress = Math.min((now - startedAt) / animationDuration, 1);
const settled = Math.floor(progress * characters.length);
setDisplay(
characters
.map((character, index) => {
if (index < settled || character === " ") return character;
return glyphs[Math.floor(Math.random() * glyphs.length)];
})
.join("")
);
}
if (now - startedAt < animationDuration) {
frame = requestAnimationFrame(animate);
} else {
setDisplay(text);
}
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [duration, glyphs, still, text]);
return (
<span
data-slot="text-scramble"
className={cn("inline-block whitespace-pre", className)}
style={style}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">{still ? text : display}</span>
</span>
);
}
/* ----------------------------------------------------------------------------
* ReasoningText.
* ------------------------------------------------------------------------- */
const DEFAULT_PHRASES = [
"Thinking",
"Reading the context",
"Connecting the details",
"Forming a response",
];
const CASCADE_STAGGER = 0.025;
export type ReasoningTextVariant = "cascade" | "swap" | "scramble";
export interface ReasoningTextProps {
/** Phrases cycled through while the agent works. */
phrases?: string[];
/** Appended to every phrase; "…" by default. */
suffix?: string;
/** Animation used when the active phrase changes. */
variant?: ReasoningTextVariant;
/** Milliseconds each phrase remains visible. */
interval?: number;
/** Seconds taken for one shimmer pass. */
shimmerDuration?: number;
/** Optional leading visual. Defaults to a terminal-style ASCII loader. */
indicator?: React.ReactNode;
className?: string;
}
type PhraseProps = {
phrase: string;
suffix: string;
reduced: boolean;
shimmerDuration: number;
};
function CascadePhrase({ phrase, suffix, reduced, shimmerDuration }: PhraseProps) {
const text = `${phrase}${suffix}`;
if (reduced) {
return (
<span
className="shimmer col-start-1 row-start-1 inline-block justify-self-start whitespace-pre"
style={shimmerStyle(shimmerDuration)}
>
{text}
</span>
);
}
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className="col-start-1 row-start-1 inline-block justify-self-start whitespace-pre"
initial="initial"
animate="animate"
exit="exit"
>
{text.split("").map((character, characterIndex) => (
<motion.span
// Position is the stable cascade slot identity.
key={characterIndex}
custom={characterIndex * CASCADE_STAGGER}
variants={{
initial: { opacity: 0, y: "100%" },
animate: (delay: number) => ({
opacity: 1,
y: "0%",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number) => ({
opacity: 0,
y: "-100%",
transition: {
duration: 0.14,
ease: EASE_OUT,
delay: delay * 0.45,
},
}),
}}
className="shimmer inline-block whitespace-pre will-change-[opacity,transform]"
style={shimmerStyle(shimmerDuration)}
>
{character}
</motion.span>
))}
</motion.span>
</AnimatePresence>
);
}
function SwapPhrase({ phrase, suffix, reduced, shimmerDuration }: PhraseProps) {
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className="shimmer col-start-1 row-start-1 inline-block justify-self-start whitespace-nowrap will-change-[opacity,transform]"
style={shimmerStyle(shimmerDuration)}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 3 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={{
duration: reduced ? 0.12 : 0.2,
ease: EASE_OUT,
}}
>
{phrase}
{suffix}
</motion.span>
</AnimatePresence>
);
}
function ScramblePhrase({ phrase, suffix, shimmerDuration }: PhraseProps) {
return (
<TextScramble
text={`${phrase}${suffix}`}
className="shimmer col-start-1 row-start-1 justify-self-start tabular-nums"
style={shimmerStyle(shimmerDuration)}
/>
);
}
function ReasoningText({
phrases = DEFAULT_PHRASES,
suffix = "…",
variant = "cascade",
interval = 1800,
shimmerDuration = 2.2,
indicator,
className,
}: ReasoningTextProps) {
const reduced = useReducedMotion() ?? false;
const [index, setIndex] = React.useState(0);
const statusId = React.useId();
const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES;
const phrase = safePhrases[index % safePhrases.length] ?? "";
const longestPhrase = safePhrases.reduce((longest, current) =>
current.length > longest.length ? current : longest
);
const phraseProps = { phrase, suffix, reduced, shimmerDuration };
React.useEffect(() => {
if (safePhrases.length < 2) return;
const timer = window.setInterval(() => {
setIndex((current) => (current + 1) % safePhrases.length);
}, Math.max(600, interval));
return () => window.clearInterval(timer);
}, [interval, safePhrases.length]);
return (
<span
data-slot="reasoning-text"
data-variant={variant}
role="status"
aria-live="polite"
aria-labelledby={statusId}
className={cn(
"inline-flex items-center gap-2 text-sm font-medium text-muted-foreground",
className
)}
>
<span
data-slot="reasoning-text-indicator"
aria-hidden="true"
className="inline-flex size-3 shrink-0 items-center justify-center"
>
{indicator ?? <AsciiLoader />}
</span>
<span aria-hidden="true" className="grid overflow-hidden text-left">
{/* The invisible copy holds the width so a phrase change never reflows. */}
<span className="invisible col-start-1 row-start-1 whitespace-nowrap">
{longestPhrase}
{suffix}
</span>
{variant === "cascade" ? (
<CascadePhrase {...phraseProps} />
) : variant === "scramble" ? (
<ScramblePhrase {...phraseProps} />
) : (
<SwapPhrase {...phraseProps} />
)}
</span>
<span id={statusId} className="sr-only">
{phrase}
</span>
</span>
);
}
export { AsciiLoader, ReasoningText, TextScramble };Ready to try it? Three commands and the app runs against a stub model.