Reasoning
Collapsible AI reasoning trace with streaming support and ordered or free-form steps.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/reasoning.jsonStorybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in StorybookCode
"use client";
import { useCallback, useEffect, useId, useState } from "react";
import { Brain, ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
export type ReasoningProps = {
/** Free-form reasoning content; renders when `steps` is absent. */
children?: React.ReactNode;
className?: string;
/** Seconds the model spent reasoning, shown in the header. */
duration?: number;
/** Whether the reasoning trace is still streaming; auto-expands the panel. */
isStreaming?: boolean;
/** Called whenever the expanded state changes. */
onOpenChange?: (open: boolean) => void;
/** Reasoning steps rendered as an ordered list when expanded. */
steps?: string[];
};
function ReasoningTrigger({
contentId,
duration,
isOpen,
isStreaming,
onToggle,
}: {
contentId: string;
duration?: number;
isOpen: boolean;
isStreaming: boolean;
onToggle: () => void;
}) {
return (
<button
aria-controls={contentId}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
<Brain className="size-4 shrink-0" />
<span className="font-medium">
{isStreaming ? "Reasoning" : "Reasoned"}
{typeof duration === "number" ? ` for ${duration}s` : null}
</span>
{isStreaming ? <span className="animate-pulse">…</span> : null}
<ChevronDown
className={cn(
"ml-auto size-4 shrink-0 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</button>
);
}
function ReasoningContent({
children,
contentId,
isStreaming,
steps,
}: {
children?: React.ReactNode;
contentId: string;
isStreaming: boolean;
steps?: string[];
}) {
const hasSteps = steps !== undefined && steps.length > 0;
return (
<div
className="border-t border-border px-3 py-2 text-sm text-muted-foreground"
id={contentId}
>
{hasSteps ? (
<ol className="list-decimal space-y-1 pl-4">
{steps.map((step, index) => (
<li
className="whitespace-pre-wrap"
key={`${index}-${step.slice(0, 12)}`}
>
{step}
</li>
))}
</ol>
) : (
<div className="whitespace-pre-wrap">{children}</div>
)}
{isStreaming ? (
<span aria-hidden className="ml-0.5 animate-pulse">
▋
</span>
) : null}
</div>
);
}
/**
* Collapsible AI reasoning trace block.
*
* Shows a toggleable header ("Reasoning" while streaming, "Reasoned" once
* settled) and an expandable body that renders either ordered `steps` or
* free-form `children`. Auto-expands while `isStreaming` is true.
*
* @example
* <Reasoning duration={4} steps={["Parse the request", "Check the cache"]} />
*/
export const Reasoning = ({
children,
className,
duration,
isStreaming = false,
onOpenChange,
ref,
steps,
}: ReasoningProps & { ref?: React.Ref<HTMLDivElement> }) => {
const [isOpen, setIsOpen] = useState(isStreaming);
const contentId = useId();
useEffect(() => {
if (isStreaming) {
requestAnimationFrame(() => {
setIsOpen(true);
});
}
}, [isStreaming]);
const handleToggle = useCallback(() => {
setIsOpen((previous) => {
const next = !previous;
onOpenChange?.(next);
return next;
});
}, [onOpenChange]);
return (
<div
className={cn("rounded-lg border border-border bg-muted/30", className)}
ref={ref}
>
<ReasoningTrigger
contentId={contentId}
duration={duration}
isOpen={isOpen}
isStreaming={isStreaming}
onToggle={handleToggle}
/>
{isOpen ? (
<ReasoningContent
contentId={contentId}
isStreaming={isStreaming}
steps={steps}
>
{children}
</ReasoningContent>
) : null}
</div>
);
};
Reasoning.displayName = "Reasoning";