A thought-for-12s collapsible that shimmers while the model reasons, then blur-crossfades to the elapsed label with muted reasoning text below.
npx shadcn@latest add @paragon/reasoning-disclosureAlso installs: shimmer-text, streaming-text
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight } from "lucide-react";
import { ShimmerText } from "@/registry/paragon/ui/shimmer-text";
import { cn } from "@/lib/utils";
export interface ReasoningDisclosureProps
extends React.ComponentProps<"div"> {
/** Live shimmer on the summary while the model is still reasoning. */
streaming?: boolean;
/** Seconds of thought, shown as "Thought for Ns" once streaming ends. */
duration?: number;
/** Label while streaming. */
streamingLabel?: string;
/** Override the finished label ("Thought for Ns" by default). */
label?: string;
defaultExpanded?: boolean;
/** Controlled expansion. */
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** Disables shimmer and crossfade motion. */
static?: boolean;
}
/**
* "Thought for 12s" — a collapsible for model reasoning. The summary row
* blur-crossfades between a live shimmering label while streaming and the
* final elapsed label; the muted reasoning text expands underneath via
* grid-template-rows so the open/close is interruptible.
*/
export function ReasoningDisclosure({
streaming = false,
duration,
streamingLabel = "Thinking",
label,
defaultExpanded = false,
expanded,
onExpandedChange,
static: isStatic = false,
className,
children,
...props
}: ReasoningDisclosureProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const panelId = `reasoning-panel-${id}`;
const reducedMotion = useReducedMotion();
const [internalOpen, setInternalOpen] = React.useState(defaultExpanded);
const open = expanded ?? internalOpen;
const toggle = () => {
const next = !open;
setInternalOpen(next);
onExpandedChange?.(next);
};
const doneLabel =
label ??
(duration !== undefined ? `Thought for ${duration}s` : "Reasoning");
const blur = isStatic || reducedMotion ? "blur(0px)" : "blur(4px)";
return (
<div data-slot="reasoning-disclosure" className={cn("w-full", className)} {...props}>
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={toggle}
className="group flex items-center gap-1.5 rounded-md py-1 pr-1.5 text-[13px] text-muted-foreground transition-[color] duration-150 ease-out hover:text-foreground"
>
<ChevronRight
aria-hidden
className={cn(
"size-3.5 shrink-0 transition-transform duration-200 ease-out",
open && "rotate-90",
)}
/>
<span className="inline-grid items-baseline">
{/* Sizing spans keep the row from jumping when the label swaps. */}
<span aria-hidden className="invisible whitespace-nowrap [grid-area:1/1]">
{streamingLabel}
</span>
<span aria-hidden className="invisible whitespace-nowrap [grid-area:1/1]">
{doneLabel}
</span>
<AnimatePresence initial={false}>
<motion.span
key={streaming ? "streaming" : "done"}
className="whitespace-nowrap [grid-area:1/1]"
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
filter: blur,
transition: { duration: 0.15 },
}}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
{streaming ? (
<ShimmerText static={isStatic}>{streamingLabel}</ShimmerText>
) : (
<span className="tabular-nums">{doneLabel}</span>
)}
</motion.span>
</AnimatePresence>
</span>
</button>
<div
className="grid transition-[grid-template-rows] duration-250 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div id={panelId} className="min-h-0 overflow-hidden">
<div className="mt-1 mb-1 ml-[7px] border-l pl-4 text-[13px] leading-relaxed text-muted-foreground">
{children}
</div>
</div>
</div>
</div>
);
}