import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/pandacoderz-ui/reasoning";
const fullText = `The user wants to know whether to stream. **Streaming** lowers perceived latency because tokens appear as they are generated.
- Cost is identical either way.
- The UI needs a stick-to-bottom scroll container.
- Partial markdown must render without flicker.
So the recommendation is to stream.`;
export default function ReasoningDemo() {
const [text, setText] = React.useState("");
const [streaming, setStreaming] = React.useState(false);
const run = React.useCallback(() => {
setText("");
setStreaming(true);
let i = 0;
const id = window.setInterval(() => {
i += 4;
setText(fullText.slice(0, i));
if (i >= fullText.length) {
window.clearInterval(id);
setStreaming(false);
}
}, 30);
}, []);
React.useEffect(() => {
run();
}, [run]);
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<Reasoning isStreaming={streaming}>
<ReasoningTrigger />
<ReasoningContent>{text}</ReasoningContent>
</Reasoning>
<div>
<Button variant="outline" size="sm" onClick={run} disabled={streaming}>
Replay
</Button>
</div>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/reasoning.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/reasoning.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/reasoning.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/reasoning.jsonInstall the dependencies:
npm install streamdown @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add collapsibleCopy the source into your project:
"use client";
import * as React from "react";
import { ChevronDownIcon, SparklesIcon } from "@heroicons-animated/react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { useOnChange } from "@/lib/use-on-change";
import { cn } from "@/lib/utils";
type ReasoningContextValue = {
isStreaming: boolean;
label: string;
};
const ReasoningContext = React.createContext<ReasoningContextValue | null>(
null,
);
function useReasoningContext(component: string): ReasoningContextValue {
const ctx = React.useContext(ReasoningContext);
if (!ctx) {
throw new Error(`${component} must be used within <Reasoning>`);
}
return ctx;
}
type ReasoningProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "defaultOpen" | "onOpenChange"
> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
function Reasoning({
className,
isStreaming = false,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
...props
}: ReasoningProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = React.useState(
defaultOpen || isStreaming,
);
const open = isControlled ? openProp : internalOpen;
const [durationLabel, setDurationLabel] = React.useState<string | null>(null);
const [hasStreamed, setHasStreamed] = React.useState(isStreaming);
const startedAtRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (isStreaming) {
startedAtRef.current = Date.now();
onOpenChange?.(true);
}
}, [isStreaming, onOpenChange]);
useOnChange(isStreaming, (current, previous) => {
if (!previous && current) {
setHasStreamed(true);
startedAtRef.current = Date.now();
setDurationLabel(null);
if (!isControlled) {
setInternalOpen(true);
}
onOpenChange?.(true);
}
if (previous && !current) {
const startedAt = startedAtRef.current;
const elapsedSeconds =
startedAt != null
? Math.max(1, Math.round((Date.now() - startedAt) / 1000))
: null;
setDurationLabel(
elapsedSeconds != null ? String(elapsedSeconds) : "a few",
);
startedAtRef.current = null;
if (!isControlled) {
setInternalOpen(false);
}
onOpenChange?.(false);
}
});
const label = React.useMemo(() => {
if (!hasStreamed || isStreaming) return "Thinking...";
if (durationLabel != null) {
const unit = durationLabel === "1" ? "second" : "seconds";
return `Thought for ${durationLabel} ${unit}`;
}
return "Thought for a few seconds";
}, [durationLabel, hasStreamed, isStreaming]);
const contextValue = React.useMemo(
() => ({ isStreaming, label }),
[isStreaming, label],
);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
const resolvedOpen = isStreaming ? true : nextOpen;
if (!isControlled) {
setInternalOpen(resolvedOpen);
}
onOpenChange?.(resolvedOpen);
},
[isControlled, isStreaming, onOpenChange],
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
data-slot="reasoning"
className={cn("not-prose w-full", className)}
data-streaming={isStreaming ? "true" : "false"}
open={open}
onOpenChange={handleOpenChange}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
type ReasoningTriggerProps = React.ComponentProps<typeof CollapsibleTrigger>;
function ReasoningTrigger({
className,
children,
...props
}: ReasoningTriggerProps) {
const { isStreaming, label } = useReasoningContext("ReasoningTrigger");
return (
<CollapsibleTrigger
data-slot="reasoning-trigger"
data-streaming={isStreaming ? "true" : "false"}
className={cn(
"group flex cursor-pointer items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground",
className,
)}
{...props}
>
<SparklesIcon
size={16}
className={cn("flex", isStreaming && "text-brand")}
/>
<TextShimmer
className="text-sm leading-6"
spread={10}
invertLight
disableShimmer={!isStreaming}
>
{children ?? label}
</TextShimmer>
<ChevronDownIcon
size={16}
className="ml-0.5 flex opacity-0 transition-all group-hover:opacity-100 group-data-[state=open]:rotate-180 group-data-[state=open]:group-data-[streaming=false]:opacity-100"
/>
</CollapsibleTrigger>
);
}
type ReasoningContentProps = Omit<
React.ComponentProps<typeof CollapsibleContent>,
"children"
> & {
children: string;
};
function ReasoningContent({
className,
children,
...props
}: ReasoningContentProps) {
return (
<CollapsibleContent
data-slot="reasoning-content"
className={cn(
"mt-2 ml-2 overflow-hidden border-l pl-3 data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className,
)}
{...props}
>
<Streamdown
className={cn(
"prose max-w-none text-sm leading-6 font-normal text-muted-foreground",
"[&_p]:mb-2.5",
"prose-strong:font-medium prose-strong:text-foreground",
"**:data-[streamdown=list-item]:py-0.25 **:data-[streamdown=list-item]:pl-4 **:data-[streamdown=list-item]:marker:text-muted-foreground/50 prose-ol:my-0 prose-ol:pl-3 prose-ul:my-0 prose-li:my-[-0.5px]",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
)}
>
{children}
</Streamdown>
</CollapsibleContent>
);
}
export { Reasoning, ReasoningTrigger, ReasoningContent };"use client";
import * as React from "react";
/**
* Runs `onChange` when `value` changes (compared against previous render).
*/
export function useOnChange<T>(
value: T,
onChange: (current: T, previous: T) => void,
isUpdated: (previous: T, current: T) => boolean = Object.is,
) {
const previousRef = React.useRef(value);
React.useEffect(() => {
const previous = previousRef.current;
if (!isUpdated(previous, value)) {
onChange(value, previous);
}
previousRef.current = value;
}, [value, onChange, isUpdated]);
}Usage
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/pandacoderz-ui/reasoning";<Reasoning isStreaming={isThinking}>
<ReasoningTrigger />
<ReasoningContent>{reasoningMarkdown}</ReasoningContent>
</Reasoning>While isStreaming is true the block stays open and the label shimmers. When it flips to false the block collapses and the label becomes “Thought for N seconds”.
API Reference
Reasoning
| Prop | Type | Description |
|---|---|---|
isStreaming |
boolean |
Whether reasoning tokens are still arriving. |
open |
boolean |
Controlled open state. |
defaultOpen |
boolean |
Initial open state when uncontrolled. |
onOpenChange |
(open: boolean) => void |
Open state callback. |
ReasoningTrigger
Renders the default label unless you pass children.
ReasoningContent
| Prop | Type | Description |
|---|---|---|
children |
string |
Markdown text. Rendered with Streamdown. |