npm install react-aria-components
"use client";
import type { ReactNode } from "react";
import { Button, Disclosure, DisclosurePanel, type DisclosureProps } from "react-aria-components";
const styles = {
root: "group flex w-full flex-col font-(family-name:--tui-font) text-(length:--tui-reasoning-font-size)",
trigger:
"inline-flex cursor-default items-center gap-1.5 self-start rounded-(--tui-reasoning-radius) border-none bg-transparent p-0 font-(family-name:--tui-font) text-(length:--tui-reasoning-font-size) leading-none font-(--tui-reasoning-font-weight) text-(--tui-reasoning-fg) outline-none transition-colors duration-(--tui-duration) data-focus-visible:[outline:var(--tui-reasoning-ring-width)_solid_var(--tui-reasoning-ring)] data-focus-visible:[outline-offset:var(--tui-reasoning-ring-offset)] data-hovered:text-(--tui-reasoning-fg-hover)",
mark: "size-[1.15em] shrink-0",
duration: "font-normal text-(--tui-reasoning-body) tabular-nums",
chevron: "size-3.5 shrink-0 transition-transform duration-(--tui-duration) group-data-expanded:rotate-90",
shimmer:
"bg-[linear-gradient(90deg,var(--tui-reasoning-fg)_35%,var(--tui-reasoning-fg-hover)_50%,var(--tui-reasoning-fg)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent [animation:tui-shimmer_2s_linear_infinite] motion-reduce:animate-none motion-reduce:text-(--tui-reasoning-fg)",
panel: "mt-(--tui-reasoning-gap) flex flex-col gap-(--tui-reasoning-step-gap) border-solid border-(--tui-reasoning-bar) border-l-(length:--tui-reasoning-bar-width) pl-(--tui-reasoning-padding) leading-relaxed text-(--tui-reasoning-body)",
step: "relative flex flex-col gap-0.5 before:absolute before:top-[0.55em] before:left-[calc((var(--tui-reasoning-padding)_+_var(--tui-reasoning-bar-width)_/_2_+_3px)_*_-1)] before:size-1.5 before:rounded-full before:bg-(--tui-reasoning-dot)",
stepTitle: "font-medium text-(--tui-reasoning-fg-hover)",
};
export interface ReasoningProps extends Omit<DisclosureProps, "className" | "children"> {
/** The line that is always shown, e.g. "Thought" or "Searched the web". */
summary: ReactNode;
/** How long it took, e.g. "4s". */
duration?: ReactNode;
/** Still going: the summary shimmers. */
isThinking?: boolean;
/** The working. Plain text, or ReasoningStep for each stage. */
children: ReactNode;
className?: string;
}
/** The working behind a reply, folded away under one line. */
export function Reasoning({ summary, duration, isThinking, children, className, ...props }: ReasoningProps) {
return (
<Disclosure {...props} className={[styles.root, className].filter(Boolean).join(" ")}>
<Button slot="trigger" className={styles.trigger}>
<svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className={`${styles.mark} ${isThinking ? "animate-pulse" : ""}`}>
<path d="M8 2.5a3.7 3.7 0 0 0-2.2 6.700c.4.300.700.800.700 1.300V11h3v-.500c0-.500.300-1 .700-1.300A3.700 3.700 0 0 0 8 2.500zM6.500 13.500h3" />
</svg>
<span className={isThinking ? styles.shimmer : undefined}>{summary}</span>
{duration && <span className={styles.duration}>{duration}</span>}
<svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={styles.chevron}>
<path d="M6 4l4 4-4 4" />
</svg>
</Button>
<DisclosurePanel>
<div data-slot="reasoning" className={styles.panel}>
{children}
</div>
</DisclosurePanel>
</Disclosure>
);
}
/** One stage of the working, marked on the rail. */
export function ReasoningStep({ title, children }: { title: ReactNode; children?: ReactNode }) {
return (
<div className={styles.step}>
<span className={styles.stepTitle}>{title}</span>
{children && <span>{children}</span>}
</div>
);
}:root {
color-scheme: light;
--tui-bg: #ffffff;
--tui-fg: #0a0a0a;
--tui-muted: #f5f5f5;
--tui-muted-fg: #737373;
--tui-border: #e5e5e5;
--tui-primary: #171717;
--tui-primary-fg: #fafafa;
--tui-ring: #a3a3a3;
--tui-success: #16a34a;
--tui-warning: #d97706;
--tui-danger: #dc2626;
--tui-radius: 8px;
--tui-border-width: 1px;
--tui-space: 4px;
--tui-shadow: none;
--tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--tui-text: 14px;
--tui-duration: 150ms;
/* Reasoning */
--tui-reasoning-gap: calc(var(--tui-space) * 3);
--tui-reasoning-step-gap: calc(var(--tui-space) * 3);
--tui-reasoning-padding: calc(var(--tui-space) * 4);
--tui-reasoning-bar-width: 2px;
--tui-reasoning-radius: calc(var(--tui-radius) * 0.5);
--tui-reasoning-font-size: var(--tui-text);
--tui-reasoning-font-weight: 500;
--tui-reasoning-fg: var(--tui-muted-fg);
--tui-reasoning-body: var(--tui-muted-fg);
--tui-reasoning-bar: var(--tui-border);
--tui-reasoning-dot: color-mix(in oklab, var(--tui-muted-fg) 60%, var(--tui-bg));
--tui-reasoning-fg-hover: var(--tui-fg);
--tui-reasoning-ring: var(--tui-ring);
--tui-reasoning-ring-width: 2px;
--tui-reasoning-ring-offset: 2px;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
--tui-bg: #0a0a0a;
--tui-fg: #fafafa;
--tui-muted: #1c1c1c;
--tui-muted-fg: #a3a3a3;
--tui-border: #2a2a2a;
--tui-primary: #fafafa;
--tui-primary-fg: #171717;
--tui-ring: #737373;
--tui-success: #22c55e;
--tui-warning: #f59e0b;
--tui-danger: #ef4444;
}
@keyframes tui-shimmer {
from { background-position: 125% 0; }
to { background-position: -125% 0; }
}Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.