Reasoning
AIThe collapsible "thinking" trace from AI chat UIs — shimmers while streaming, then auto-collapses to a summary. Height animates with the grid-rows technique (no JS measurement), and it stays fully accessible and dependency-light.
The user wants a smooth reveal. Measure the content height implicitly with a grid row, then ease grid-template-rows from 0fr to 1fr so nothing reflows. Shimmer the label while streaming, and collapse automatically once the answer is ready.
components/ui/reasoning.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Reasoning — the collapsible "thinking" trace used in AI chat UIs.
*
* While the model is streaming its thoughts it auto-expands and the label
* shimmers; when it finishes it auto-collapses to a one-line summary the user
* can re-open. Composed of three parts:
*
* <Reasoning isStreaming={busy} duration={4}>
* <ReasoningTrigger />
* <ReasoningContent>{thoughts}</ReasoningContent>
* </Reasoning>
*
* Open state is uncontrolled by default (with sensible auto behavior) but can
* be fully controlled via `open` / `onOpenChange`. Expand/collapse uses the
* `grid-template-rows: 0fr → 1fr` technique, so height animates from the
* content's real size with no JS measurement and no reflow flicker.
*/
interface ReasoningContextValue {
open: boolean;
toggle: () => void;
isStreaming: boolean;
duration?: number;
contentId: string;
}
const ReasoningContext = React.createContext<ReasoningContextValue | null>(
null,
);
function useReasoning(component: string): ReasoningContextValue {
const ctx = React.useContext(ReasoningContext);
if (!ctx) {
throw new Error(`<${component}> must be used within <Reasoning>`);
}
return ctx;
}
export interface ReasoningProps extends Omit<
React.ComponentProps<"div">,
"onChange"
> {
/** True while the model is still producing its reasoning. */
isStreaming?: boolean;
/** Seconds spent thinking, shown in the trigger once finished. */
duration?: number;
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. Defaults to `isStreaming`. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Reasoning({
isStreaming = false,
duration,
open: openProp,
defaultOpen,
onOpenChange,
className,
children,
...props
}: ReasoningProps) {
const isControlled = openProp !== undefined;
const [openState, setOpenState] = React.useState(defaultOpen ?? isStreaming);
const [prevStreaming, setPrevStreaming] = React.useState(isStreaming);
const contentId = React.useId();
// Auto-expand while streaming, auto-collapse the moment it finishes.
// Adjusting state during render (rather than in an effect) is the
// recommended way to react to a prop change — React re-renders immediately
// with no flash and no extra commit.
if (isStreaming !== prevStreaming) {
setPrevStreaming(isStreaming);
if (!isControlled) setOpenState(isStreaming);
}
const open = isControlled ? openProp : openState;
const toggle = React.useCallback(() => {
const next = !(isControlled ? openProp : openState);
if (!isControlled) setOpenState(next);
onOpenChange?.(next);
}, [isControlled, openProp, openState, onOpenChange]);
return (
<ReasoningContext.Provider
value={{ open, toggle, isStreaming, duration, contentId }}
>
<div
data-slot="reasoning"
data-state={open ? "open" : "closed"}
className={cn(
"overflow-hidden rounded-xl border border-zinc-200 bg-white/50 text-sm dark:border-zinc-800 dark:bg-zinc-900/50",
className,
)}
{...props}
>
{children}
</div>
</ReasoningContext.Provider>
);
}
export function ReasoningTrigger({
className,
children,
...props
}: React.ComponentProps<"button">) {
const { open, toggle, isStreaming, duration, contentId } =
useReasoning("ReasoningTrigger");
const label = isStreaming
? "Thinking"
: duration != null
? `Thought for ${duration}s`
: "Reasoning";
return (
<button
type="button"
data-slot="reasoning-trigger"
aria-expanded={open}
aria-controls={contentId}
onClick={toggle}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-zinc-500 dark:text-zinc-400",
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
"focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:focus-visible:ring-zinc-50/50",
className,
)}
{...props}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]",
open && "rotate-90",
)}
>
<path d="m9 18 6-6-6-6" />
</svg>
{children ?? (
<span
className={cn(
"font-medium",
// Shimmer sweep while streaming. The two stops are plain Tailwind
// custom properties so the effect carries its own light/dark
// colors; only the @keyframes ships as CSS alongside this file.
isStreaming && [
"bg-clip-text text-transparent",
"[--shimmer-dim:#71717a] [--shimmer-lit:#09090b]",
"dark:[--shimmer-dim:#a1a1aa] dark:[--shimmer-lit:#fafafa]",
"bg-[linear-gradient(90deg,var(--shimmer-dim)_30%,var(--shimmer-lit),var(--shimmer-dim)_70%)]",
"bg-size-[200%_auto]",
"animate-[reasoning-shimmer_1.6s_linear_infinite]",
"motion-reduce:animate-none",
],
)}
>
{label}
</span>
)}
</button>
);
}
export function ReasoningContent({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { open, contentId } = useReasoning("ReasoningContent");
return (
<div
id={contentId}
role="region"
data-slot="reasoning-content"
data-state={open ? "open" : "closed"}
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
{...props}
>
<div className="overflow-hidden">
<div className="border-t border-zinc-200 px-3 pt-2.5 pb-3 dark:border-zinc-800">
<div
className={cn(
"border-l-2 border-zinc-200 pl-3 leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400",
className,
)}
>
{children}
</div>
</div>
</div>
</div>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/reasoning.json1. Install dependencies
Terminal
npm install clsx tailwind-merge2. Copy the source into your project
components/ui/reasoning.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Reasoning — the collapsible "thinking" trace used in AI chat UIs.
*
* While the model is streaming its thoughts it auto-expands and the label
* shimmers; when it finishes it auto-collapses to a one-line summary the user
* can re-open. Composed of three parts:
*
* <Reasoning isStreaming={busy} duration={4}>
* <ReasoningTrigger />
* <ReasoningContent>{thoughts}</ReasoningContent>
* </Reasoning>
*
* Open state is uncontrolled by default (with sensible auto behavior) but can
* be fully controlled via `open` / `onOpenChange`. Expand/collapse uses the
* `grid-template-rows: 0fr → 1fr` technique, so height animates from the
* content's real size with no JS measurement and no reflow flicker.
*/
interface ReasoningContextValue {
open: boolean;
toggle: () => void;
isStreaming: boolean;
duration?: number;
contentId: string;
}
const ReasoningContext = React.createContext<ReasoningContextValue | null>(
null,
);
function useReasoning(component: string): ReasoningContextValue {
const ctx = React.useContext(ReasoningContext);
if (!ctx) {
throw new Error(`<${component}> must be used within <Reasoning>`);
}
return ctx;
}
export interface ReasoningProps extends Omit<
React.ComponentProps<"div">,
"onChange"
> {
/** True while the model is still producing its reasoning. */
isStreaming?: boolean;
/** Seconds spent thinking, shown in the trigger once finished. */
duration?: number;
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. Defaults to `isStreaming`. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Reasoning({
isStreaming = false,
duration,
open: openProp,
defaultOpen,
onOpenChange,
className,
children,
...props
}: ReasoningProps) {
const isControlled = openProp !== undefined;
const [openState, setOpenState] = React.useState(defaultOpen ?? isStreaming);
const [prevStreaming, setPrevStreaming] = React.useState(isStreaming);
const contentId = React.useId();
// Auto-expand while streaming, auto-collapse the moment it finishes.
// Adjusting state during render (rather than in an effect) is the
// recommended way to react to a prop change — React re-renders immediately
// with no flash and no extra commit.
if (isStreaming !== prevStreaming) {
setPrevStreaming(isStreaming);
if (!isControlled) setOpenState(isStreaming);
}
const open = isControlled ? openProp : openState;
const toggle = React.useCallback(() => {
const next = !(isControlled ? openProp : openState);
if (!isControlled) setOpenState(next);
onOpenChange?.(next);
}, [isControlled, openProp, openState, onOpenChange]);
return (
<ReasoningContext.Provider
value={{ open, toggle, isStreaming, duration, contentId }}
>
<div
data-slot="reasoning"
data-state={open ? "open" : "closed"}
className={cn(
"overflow-hidden rounded-xl border border-zinc-200 bg-white/50 text-sm dark:border-zinc-800 dark:bg-zinc-900/50",
className,
)}
{...props}
>
{children}
</div>
</ReasoningContext.Provider>
);
}
export function ReasoningTrigger({
className,
children,
...props
}: React.ComponentProps<"button">) {
const { open, toggle, isStreaming, duration, contentId } =
useReasoning("ReasoningTrigger");
const label = isStreaming
? "Thinking"
: duration != null
? `Thought for ${duration}s`
: "Reasoning";
return (
<button
type="button"
data-slot="reasoning-trigger"
aria-expanded={open}
aria-controls={contentId}
onClick={toggle}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-zinc-500 dark:text-zinc-400",
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
"focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:focus-visible:ring-zinc-50/50",
className,
)}
{...props}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]",
open && "rotate-90",
)}
>
<path d="m9 18 6-6-6-6" />
</svg>
{children ?? (
<span
className={cn(
"font-medium",
// Shimmer sweep while streaming. The two stops are plain Tailwind
// custom properties so the effect carries its own light/dark
// colors; only the @keyframes ships as CSS alongside this file.
isStreaming && [
"bg-clip-text text-transparent",
"[--shimmer-dim:#71717a] [--shimmer-lit:#09090b]",
"dark:[--shimmer-dim:#a1a1aa] dark:[--shimmer-lit:#fafafa]",
"bg-[linear-gradient(90deg,var(--shimmer-dim)_30%,var(--shimmer-lit),var(--shimmer-dim)_70%)]",
"bg-size-[200%_auto]",
"animate-[reasoning-shimmer_1.6s_linear_infinite]",
"motion-reduce:animate-none",
],
)}
>
{label}
</span>
)}
</button>
);
}
export function ReasoningContent({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { open, contentId } = useReasoning("ReasoningContent");
return (
<div
id={contentId}
role="region"
data-slot="reasoning-content"
data-state={open ? "open" : "closed"}
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
{...props}
>
<div className="overflow-hidden">
<div className="border-t border-zinc-200 px-3 pt-2.5 pb-3 dark:border-zinc-800">
<div
className={cn(
"border-l-2 border-zinc-200 pl-3 leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400",
className,
)}
>
{children}
</div>
</div>
</div>
</div>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
isStreaming | boolean | false | True while the model is producing its reasoning. Auto-expands the panel and shimmers the label; collapses automatically when it flips back to false. |
duration | number | — | Seconds spent thinking, shown in the trigger once finished. |
open / defaultOpen | boolean | — | Controlled / uncontrolled open state. Defaults to isStreaming. |
onOpenChange | (open: boolean) => void | — | Fires when the user toggles the panel. |
Dependencies
clsxtailwind-merge