Reasoning Disclosure
A model's reasoning, shimmering "Thinking" while it streams and folding into "Thought for 8s" once the answer starts. Reopen it any time.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/motion/button";
import { ReasoningDisclosure } from "@/components/motion/reasoning-disclosure";
import { StreamingText } from "@/components/motion/streaming-text";
const REASONING =
"The user wants the cheapest way to cache this endpoint. It's read heavy and the data changes about once an hour. A CDN cache with a one hour max-age would work, but invalidating after a write matters more than raw speed here. Tagging responses and purging the tag on write keeps it fresh without a short TTL.";
const ANSWER =
"Cache it at the CDN with a long max-age and tag each response. When the data changes, purge that tag, so readers never wait on a stale copy.";
/** Characters per second for each phase. */
const THINK_SPEED = 90;
const ANSWER_SPEED = 60;
function useTyped(text: string, speed: number, start: boolean, run: number) {
const reduce = useReducedMotion();
const [count, setCount] = useState(0);
// run is never read; bumping it is what restarts the typing.
// biome-ignore lint/correctness/useExhaustiveDependencies: run is intentional, see comment above.
useEffect(() => {
setCount(0);
if (!start) return;
if (reduce) {
setCount(text.length);
return;
}
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const next = Math.min(text.length, Math.floor(((now - startedAt) / 1000) * speed));
setCount(next);
if (next < text.length) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [text, speed, start, reduce, run]);
return count;
}
export function ReasoningDisclosurePreview() {
const [run, setRun] = useState(0);
const thought = useTyped(REASONING, THINK_SPEED, true, run);
const thinking = thought < REASONING.length;
const answered = useTyped(ANSWER, ANSWER_SPEED, !thinking, run);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<ReasoningDisclosure key={run} status={thinking ? "thinking" : "done"}>
{REASONING.slice(0, thought)}
</ReasoningDisclosure>
{thinking ? null : <StreamingText text={ANSWER.slice(0, answered)} streaming={answered < ANSWER.length} />}
<div>
<Button variant="ghost" size="sm" onClick={() => setRun((r) => r + 1)}>
<RotateCcw aria-hidden="true" className="h-3.5 w-3.5" />
Replay
</Button>
</div>
</div>
);
}
"use client";
// easeui.dev/components/agents/reasoning-disclosure
import { Brain, ChevronDown } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export type ReasoningStatus = "thinking" | "done";
export interface ReasoningDisclosureProps {
/** "thinking" while the model reasons, "done" once the answer starts. */
status: ReasoningStatus;
/** The reasoning text so far. Append to it as it streams. */
children: ReactNode;
/** Seconds spent thinking, shown once done. Leave it out and the component times it. */
duration?: number;
/** Controlled open state. */
open?: boolean;
/** Starting open state when uncontrolled. Default true, so the reasoning shows while it streams. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Folds the reasoning away once status turns "done", unless someone opened or closed it by hand. Default true. */
collapseOnDone?: boolean;
/** Header text while thinking. Default "Thinking". */
thinkingLabel?: string;
className?: string;
}
/** With a 200% wide gradient, a 200 point position swing moves it one full tile, so the loop never jumps. */
const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }];
const SHIMMER_MS = 2000;
// Only the shown label takes up space, so the chevron hugs it. The other one fades out on top of it.
const SWAP = "whitespace-nowrap text-left transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none";
const SWAP_SHOWN = "relative translate-y-0 opacity-100";
const SWAP_GONE = "pointer-events-none absolute left-0 top-0 opacity-0";
function prefersReducedMotion() {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function formatDuration(seconds: number): string {
const whole = Math.max(1, Math.round(seconds));
if (whole < 60) return `${whole}s`;
const minutes = Math.floor(whole / 60);
const rest = whole % 60;
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
/** Tracks how long status stayed "thinking", restarting whenever it goes back to thinking. */
function useThinkingTime(status: ReasoningStatus) {
const startedAt = useRef<number | null>(null);
const [seconds, setSeconds] = useState<number | null>(null);
useEffect(() => {
if (status === "thinking") {
startedAt.current = Date.now();
setSeconds(null);
return;
}
if (startedAt.current !== null) setSeconds((Date.now() - startedAt.current) / 1000);
}, [status]);
return seconds;
}
/**
* A model's reasoning, shown while it thinks and folded away once it answers. The header
* shimmers "Thinking" while it works, then crossfades to "Thought for 8s". The reasoning
* stays pinned to its newest line as it streams, and can be reopened any time.
*/
export function ReasoningDisclosure({
status,
children,
duration,
open,
defaultOpen = true,
onOpenChange,
collapseOnDone = true,
thinkingLabel = "Thinking",
className,
}: ReasoningDisclosureProps) {
const panelId = useId();
const shimmerRef = useRef<HTMLSpanElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const touched = useRef(false);
// A message loaded from history is already done, so it starts folded rather than folding on mount.
const [uncontrolled, setUncontrolled] = useState(defaultOpen && !(status === "done" && collapseOnDone));
const isControlled = open !== undefined;
const isOpen = isControlled ? open : uncontrolled;
const thinking = status === "thinking";
const timed = useThinkingTime(status);
const seconds = duration ?? timed;
const doneLabel = seconds === null ? "Thought for a moment" : `Thought for ${formatDuration(seconds)}`;
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolled(next);
onOpenChange?.(next);
};
// Folding on its own only happens if nobody has opened or closed it by hand.
// biome-ignore lint/correctness/useExhaustiveDependencies: only the change to "done" should fold it, not every render.
useEffect(() => {
if (thinking || !collapseOnDone || touched.current) return;
setOpen(false);
}, [thinking, collapseOnDone]);
// While thinking, keep the newest line in view as the text grows.
// biome-ignore lint/correctness/useExhaustiveDependencies: children is the trigger, the effect reads the DOM.
useEffect(() => {
const scroller = scrollRef.current;
if (thinking && scroller) scroller.scrollTop = scroller.scrollHeight;
}, [children, thinking]);
useEffect(() => {
const element = shimmerRef.current;
if (!thinking || !element || typeof element.animate !== "function" || prefersReducedMotion()) return;
const animation = element.animate(SHIMMER_SWEEP, {
duration: SHIMMER_MS,
iterations: Number.POSITIVE_INFINITY,
easing: "linear",
});
return () => animation.cancel();
}, [thinking]);
return (
<div className={cn("flex flex-col", className)}>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => {
touched.current = true;
setOpen(!isOpen);
}}
className={cn(
"group relative -ml-1 inline-flex min-h-9 w-fit touch-manipulation items-center gap-1.5 rounded-full px-1 text-sm text-muted-foreground outline-none",
"transition-colors duration-150 ease-out hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-foreground/40",
"after:absolute after:-inset-y-1 after:-inset-x-1",
)}
>
<Brain aria-hidden="true" className="h-4 w-4 shrink-0" />
<span className="relative">
<span
ref={shimmerRef}
aria-hidden={!thinking}
className={cn(
SWAP,
"block bg-clip-text font-medium [-webkit-background-clip:text]",
thinking ? cn(SWAP_SHOWN, "text-transparent") : cn(SWAP_GONE, "-translate-y-1"),
)}
style={{
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)",
backgroundSize: "200% 100%",
}}
>
{thinkingLabel}
</span>
<span aria-hidden={thinking} className={cn(SWAP, "block", thinking ? cn(SWAP_GONE, "translate-y-1") : SWAP_SHOWN)}>
{doneLabel}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none",
isOpen && "rotate-180",
)}
/>
<span aria-live="polite" className="sr-only">
{thinking ? "" : doneLabel}
</span>
</button>
{/* Grows from zero rows to its natural height with a grid transition, so nothing is measured. */}
<section
id={panelId}
aria-label="Reasoning"
aria-busy={thinking}
inert={!isOpen}
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div
ref={scrollRef}
className={cn(
"ml-2 mt-1.5 max-h-60 overflow-y-auto border-l-2 border-border pl-3.5 text-sm leading-6 text-muted-foreground",
"transition-opacity duration-200 ease-out motion-reduce:transition-none",
isOpen ? "opacity-100" : "opacity-0",
)}
>
{children}
</div>
</div>
</section>
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/reasoning-disclosure
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/reasoning-disclosure.tsx "use client"; // easeui.dev/components/agents/reasoning-disclosure import { Brain, ChevronDown } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type ReasoningStatus = "thinking" | "done"; export interface ReasoningDisclosureProps { /** "thinking" while the model reasons, "done" once the answer starts. */ status: ReasoningStatus; /** The reasoning text so far. Append to it as it streams. */ children: ReactNode; /** Seconds spent thinking, shown once done. Leave it out and the component times it. */ duration?: number; /** Controlled open state. */ open?: boolean; /** Starting open state when uncontrolled. Default true, so the reasoning shows while it streams. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Folds the reasoning away once status turns "done", unless someone opened or closed it by hand. Default true. */ collapseOnDone?: boolean; /** Header text while thinking. Default "Thinking". */ thinkingLabel?: string; className?: string; } /** With a 200% wide gradient, a 200 point position swing moves it one full tile, so the loop never jumps. */ const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }]; const SHIMMER_MS = 2000; // Only the shown label takes up space, so the chevron hugs it. The other one fades out on top of it. const SWAP = "whitespace-nowrap text-left transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none"; const SWAP_SHOWN = "relative translate-y-0 opacity-100"; const SWAP_GONE = "pointer-events-none absolute left-0 top-0 opacity-0"; function prefersReducedMotion() { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function formatDuration(seconds: number): string { const whole = Math.max(1, Math.round(seconds)); if (whole < 60) return `${whole}s`; const minutes = Math.floor(whole / 60); const rest = whole % 60; return rest ? `${minutes}m ${rest}s` : `${minutes}m`; } /** Tracks how long status stayed "thinking", restarting whenever it goes back to thinking. */ function useThinkingTime(status: ReasoningStatus) { const startedAt = useRef<number | null>(null); const [seconds, setSeconds] = useState<number | null>(null); useEffect(() => { if (status === "thinking") { startedAt.current = Date.now(); setSeconds(null); return; } if (startedAt.current !== null) setSeconds((Date.now() - startedAt.current) / 1000); }, [status]); return seconds; } /** * A model's reasoning, shown while it thinks and folded away once it answers. The header * shimmers "Thinking" while it works, then crossfades to "Thought for 8s". The reasoning * stays pinned to its newest line as it streams, and can be reopened any time. */ export function ReasoningDisclosure({ status, children, duration, open, defaultOpen = true, onOpenChange, collapseOnDone = true, thinkingLabel = "Thinking", className, }: ReasoningDisclosureProps) { const panelId = useId(); const shimmerRef = useRef<HTMLSpanElement>(null); const scrollRef = useRef<HTMLDivElement>(null); const touched = useRef(false); // A message loaded from history is already done, so it starts folded rather than folding on mount. const [uncontrolled, setUncontrolled] = useState(defaultOpen && !(status === "done" && collapseOnDone)); const isControlled = open !== undefined; const isOpen = isControlled ? open : uncontrolled; const thinking = status === "thinking"; const timed = useThinkingTime(status); const seconds = duration ?? timed; const doneLabel = seconds === null ? "Thought for a moment" : `Thought for ${formatDuration(seconds)}`; const setOpen = (next: boolean) => { if (!isControlled) setUncontrolled(next); onOpenChange?.(next); }; // Folding on its own only happens if nobody has opened or closed it by hand. // biome-ignore lint/correctness/useExhaustiveDependencies: only the change to "done" should fold it, not every render. useEffect(() => { if (thinking || !collapseOnDone || touched.current) return; setOpen(false); }, [thinking, collapseOnDone]); // While thinking, keep the newest line in view as the text grows. // biome-ignore lint/correctness/useExhaustiveDependencies: children is the trigger, the effect reads the DOM. useEffect(() => { const scroller = scrollRef.current; if (thinking && scroller) scroller.scrollTop = scroller.scrollHeight; }, [children, thinking]); useEffect(() => { const element = shimmerRef.current; if (!thinking || !element || typeof element.animate !== "function" || prefersReducedMotion()) return; const animation = element.animate(SHIMMER_SWEEP, { duration: SHIMMER_MS, iterations: Number.POSITIVE_INFINITY, easing: "linear", }); return () => animation.cancel(); }, [thinking]); return ( <div className={cn("flex flex-col", className)}> <button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => { touched.current = true; setOpen(!isOpen); }} className={cn( "group relative -ml-1 inline-flex min-h-9 w-fit touch-manipulation items-center gap-1.5 rounded-full px-1 text-sm text-muted-foreground outline-none", "transition-colors duration-150 ease-out hover:text-foreground", "focus-visible:ring-2 focus-visible:ring-foreground/40", "after:absolute after:-inset-y-1 after:-inset-x-1", )} > <Brain aria-hidden="true" className="h-4 w-4 shrink-0" /> <span className="relative"> <span ref={shimmerRef} aria-hidden={!thinking} className={cn( SWAP, "block bg-clip-text font-medium [-webkit-background-clip:text]", thinking ? cn(SWAP_SHOWN, "text-transparent") : cn(SWAP_GONE, "-translate-y-1"), )} style={{ backgroundImage: "linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)", backgroundSize: "200% 100%", }} > {thinkingLabel} </span> <span aria-hidden={thinking} className={cn(SWAP, "block", thinking ? cn(SWAP_GONE, "translate-y-1") : SWAP_SHOWN)}> {doneLabel} </span> </span> <ChevronDown aria-hidden="true" className={cn( "h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none", isOpen && "rotate-180", )} /> <span aria-live="polite" className="sr-only"> {thinking ? "" : doneLabel} </span> </button> {/* Grows from zero rows to its natural height with a grid transition, so nothing is measured. */} <section id={panelId} aria-label="Reasoning" aria-busy={thinking} inert={!isOpen} className={cn( "grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none", isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", )} > <div className="overflow-hidden"> <div ref={scrollRef} className={cn( "ml-2 mt-1.5 max-h-60 overflow-y-auto border-l-2 border-border pl-3.5 text-sm leading-6 text-muted-foreground", "transition-opacity duration-200 ease-out motion-reduce:transition-none", isOpen ? "opacity-100" : "opacity-0", )} > {children} </div> </div> </section> </div> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });components/motion/streaming-text.tsx "use client"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface StreamingTextProps { /** The text so far. Append to it as more arrives; characters already shown never replay. */ text: string; /** Shows a blinking cursor at the end, for while more is still on its way. Default false. */ streaming?: boolean; className?: string; } /** Splits into individual characters, so each one can fade in on its own as it arrives. */ function tokenize(text: string): string[] { return Array.from(text); } /** * Reveals text as it streams in. Feed it a growing string and each new * character fades in fast enough to read as one smooth ribbon, not single * letters popping in. Characters already shown never replay. */ export function StreamingText({ text, streaming = false, className }: StreamingTextProps) { const reduce = useReducedMotion(); const tokens = useMemo(() => tokenize(text), [text]); // Tokens shown as of the last render. A shorter text (a new message) counts as fully settled. const shown = useRef(0); const settled = Math.min(tokens.length, shown.current); useEffect(() => { shown.current = tokens.length; }, [tokens.length]); return ( <p className={cn("text-sm leading-6 text-foreground", className)}> {tokens.map((token, index) => { const isGlyph = /\S/.test(token); if (!isGlyph || index < settled || reduce) { // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. return <span key={index}>{token}</span>; } return ( <motion.span // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. key={index} initial={{ opacity: 0, y: 3 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.12, ease: EASE_OUT }} > {token} </motion.span> ); })} {streaming ? ( <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-[2px] animate-pulse bg-foreground align-middle motion-reduce:animate-none" /> ) : null} </p> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "thinking" | "done" | - | "thinking" while the model reasons, "done" once the answer starts. |
| children | ReactNode | - | The reasoning text so far. Append to it as it streams. |
| duration? | number | - | Seconds spent thinking, shown once done. Leave it out and the component times it. |
| open? | boolean | - | Controlled open state. |
| defaultOpen? | boolean | true | Starting open state when uncontrolled. Default true, so the reasoning shows while it streams. |
| onOpenChange? | ((open: boolean) => void) | - | - |
| collapseOnDone? | boolean | true | Folds the reasoning away once status turns "done", unless someone opened or closed it by hand. Default true. |
| thinkingLabel? | string | Thinking | Header text while thinking. Default "Thinking". |
| className? | string | - | - |