Reasoning
Collapsible AI reasoning trace with streaming support and ordered or free-form steps. Install Reasoning from the VLLNT UI registry with the shadcn CLI.
Collapsible AI reasoning trace with streaming support and ordered or free-form steps. Part of the ai family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Reasoning to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/reasoning.jsonSource
"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";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.