A collapsible 'thinking' panel for model reasoning: shows a shimmering 'Thinking…' while streaming, opens automatically, then collapses to 'Thought for 12s' when done.
"use client"
import * as React from "react"
import { ReasoningDisclosure } from "@/components/ballmac/reasoning-disclosure"
export default function ReasoningDisclosureDemo() {
const [streaming, setStreaming] = React.useState(true)
React.useEffect(() => {
const t = setTimeout(() => setStreaming(false), 2400)
return () => clearTimeout(t)
}, [])
return (
<div className="w-full max-w-xl">
<ReasoningDisclosure streaming={streaming} duration={12}>
The user wants the chat to stay pinned to the newest message. Pinning should only happen when they are already at
the bottom; otherwise their scroll position must be respected. A floating button can offer a jump back.
</ReasoningDisclosure>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/reasoning-disclosureInstall the dependencies.
$ pnpm add lucide-react motion@^12 radix-uiCopy the source into your project.
components/ballmac/reasoning-disclosure.tsx// Ballmac UI: Reasoning Disclosure. https://ui.ballmac.com/components/reasoning-disclosure "use client" import * as React from "react" import { ChevronRight } from "lucide-react" import { motion, useReducedMotion } from "motion/react" import { Collapsible as CollapsiblePrimitive } from "radix-ui" import { cn } from "@/lib/utils" /** "Thought for 12s", "Thought for 1m 5s", "Thought for a few seconds". */ function formatThoughtDuration(seconds: number) { if (!Number.isFinite(seconds) || seconds < 1) return "Thought for a few seconds" const total = Math.round(seconds) if (total < 60) return `Thought for ${total}s` const minutes = Math.floor(total / 60) const rest = total % 60 return rest ? `Thought for ${minutes}m ${rest}s` : `Thought for ${minutes}m` } function ShimmerLabel({ children }: { children: React.ReactNode }) { const reduceMotion = useReducedMotion() if (reduceMotion) return <span>{children}</span> return ( <motion.span className="bg-clip-text text-transparent" style={{ backgroundImage: "linear-gradient(90deg, var(--muted-foreground) 35%, var(--foreground) 50%, var(--muted-foreground) 65%)", backgroundSize: "250% 100%", }} initial={{ backgroundPosition: "100% 0%" }} animate={{ backgroundPosition: "0% 0%" }} transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }} > {children} </motion.span> ) } type ReasoningDisclosureProps = Omit<React.ComponentProps<"div">, "dir"> & { /** True while the model is still reasoning. Shows "Thinking…" and opens the panel. */ streaming?: boolean /** How long the model reasoned, in seconds. Shown as "Thought for 12s" once streaming ends. */ duration?: number /** Controlled open state. When set, streaming no longer opens or closes the panel. */ open?: boolean /** Initial open state when uncontrolled. Defaults to `streaming`. */ defaultOpen?: boolean /** Called when the panel opens or closes, including the automatic open and collapse. */ onOpenChange?: (open: boolean) => void /** Collapse automatically when streaming ends (uncontrolled only). */ autoCollapse?: boolean /** Replaces the header text. */ label?: React.ReactNode /** Classes for the content panel. */ contentClassName?: string } function ReasoningDisclosure({ streaming = false, duration, open: openProp, defaultOpen, onOpenChange, autoCollapse = true, label, contentClassName, className, children, ...props }: ReasoningDisclosureProps) { const controlled = openProp !== undefined const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? streaming) const open = controlled ? openProp : internalOpen const onOpenChangeRef = React.useRef(onOpenChange) React.useEffect(() => { onOpenChangeRef.current = onOpenChange }) const setOpen = React.useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next) onOpenChangeRef.current?.(next) }, [controlled] ) // Open when streaming starts, collapse when it ends. const wasStreaming = React.useRef(streaming) React.useEffect(() => { if (wasStreaming.current === streaming) return wasStreaming.current = streaming if (controlled) return if (streaming) setOpen(true) else if (autoCollapse) setOpen(false) }, [streaming, controlled, autoCollapse, setOpen]) const heading = label ?? (streaming ? <ShimmerLabel>Thinking…</ShimmerLabel> : duration !== undefined ? formatThoughtDuration(duration) : "Reasoning") return ( <CollapsiblePrimitive.Root data-slot="reasoning-disclosure" data-streaming={streaming || undefined} open={open} onOpenChange={setOpen} className={cn("group/reasoning w-full text-sm", className)} {...props} > <CollapsiblePrimitive.Trigger data-slot="reasoning-disclosure-trigger" className="-mx-1.5 inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span className="font-medium">{heading}</span> <ChevronRight aria-hidden="true" className="size-3.5 transition-transform duration-150 group-data-[state=open]/reasoning:rotate-90 rtl:group-data-[state=closed]/reasoning:rotate-180 motion-reduce:transition-none" /> </CollapsiblePrimitive.Trigger> <CollapsiblePrimitive.Content data-slot="reasoning-disclosure-content" className={cn( "mt-1.5 border-s-2 border-border ps-4 text-[13px] leading-6 whitespace-pre-wrap break-words text-muted-foreground", contentClassName )} > {children} </CollapsiblePrimitive.Content> </CollapsiblePrimitive.Root> ) } export { ReasoningDisclosure, formatThoughtDuration, type ReasoningDisclosureProps }Update the import paths to match your project setup.
Usage
import { ReasoningDisclosure, formatThoughtDuration } from "@/components/ballmac/reasoning-disclosure"The full example is in the Code tab above.
API reference
| Prop | Type | Default |
|---|---|---|
streamingTrue while the model is still reasoning. Shows "Thinking…" and opens the panel. | boolean | false |
durationHow long the model reasoned, in seconds. Shown as "Thought for 12s" once streaming ends. | number | — |
openControlled open state. When set, streaming no longer opens or closes the panel. | boolean | — |
defaultOpenInitial open state when uncontrolled. Defaults to `streaming`. | boolean | — |
onOpenChangeCalled when the panel opens or closes, including the automatic open and collapse. | (open: boolean) => void | — |
autoCollapseCollapse automatically when streaming ends (uncontrolled only). | boolean | true |
labelReplaces the header text. | React.ReactNode | — |
contentClassNameClasses for the content panel. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Opens or closes the reasoning (aria-expanded, aria-controls) |
| — | The shimmer is decorative and stops under reduced motion |
Use with AI
Wrap a model's reasoning text: <ReasoningDisclosure streaming={isReasoning} duration={seconds}>{text}</ReasoningDisclosure>. It opens while streaming and collapses when streaming ends unless you control open. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Reasoning Disclosure (@ballmac/reasoning-disclosure) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing reasoning or 'thinking' tokens from reasoning models above the final answer
- Any secondary, verbose content that should be visible while it is produced and tucked away afterwards
Not for
- FAQ-style expanders (use an accordion)
- Tool call details (use tool-call-card)
Registry JSON: https://ui.ballmac.com/r/reasoning-disclosure.json
Credits
Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
Streaming Text
Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.