diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json
index 7482ceb5c..2a445eae9 100644
--- a/apps/www/src/content/docs/ai-elements/meta.json
+++ b/apps/www/src/content/docs/ai-elements/meta.json
@@ -1,4 +1,4 @@
{
"title": "AI Elements",
- "pages": ["message", "prompt-input"]
+ "pages": ["message", "prompt-input", "reasoning"]
}
diff --git a/apps/www/src/content/docs/ai-elements/reasoning/demo.ts b/apps/www/src/content/docs/ai-elements/reasoning/demo.ts
new file mode 100644
index 000000000..055822de0
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/reasoning/demo.ts
@@ -0,0 +1,66 @@
+'use client';
+
+export const preview = {
+ type: 'code',
+ code: `
+
+
+
+
+ Looked at recent issues in Design System 2.
+
+
+
+
+
`
+};
+
+export const streamingDemo = {
+ type: 'code',
+ code: `function StreamingReasoning() {
+ const [streaming, setStreaming] = React.useState(false);
+ const timerRef = React.useRef(null);
+
+ React.useEffect(() => () => clearTimeout(timerRef.current), []);
+
+ return (
+
+
+
+
+
+
+
+ I'm pulling the active issues assigned to you…
+
+
+
+
+
+
+ );
+}`
+};
+
+export const customTriggerDemo = {
+ type: 'code',
+ code: `
+
+ Show the plan
+
+
+
+
+
+
`
+};
diff --git a/apps/www/src/content/docs/ai-elements/reasoning/index.mdx b/apps/www/src/content/docs/ai-elements/reasoning/index.mdx
new file mode 100644
index 000000000..db26b52f7
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/reasoning/index.mdx
@@ -0,0 +1,74 @@
+---
+title: Reasoning
+description: Collapsible "thinking" section for AI responses — auto-opens while streaming, auto-collapses on completion, with labelled steps.
+source: packages/raystack/components/reasoning
+tag: new
+---
+
+import { preview, streamingDemo, customTriggerDemo } from "./demo.ts";
+
+
+
+## Anatomy
+
+```tsx
+import { Reasoning } from '@raystack/apsara'
+
+
+
+
+
+ {/* indented detail */}
+
+
+
+```
+
+Built on `Collapsible`. Drive `streaming` from your transport; everything
+here is presentational and works anywhere — inside a
+[Message](/docs/ai-elements/message) body, a `Chat` scroller, or on its own.
+
+## API Reference
+
+### Reasoning
+
+
+
+The open state auto-follows `streaming` (open while `true`, collapsed once it
+flips back) until the user toggles the panel manually — from then on it's
+theirs.
+
+### Reasoning.Trigger
+
+Renders the default status label (shimmering "Thinking…", then "Worked for N
+seconds") plus a chevron; pass children to replace the label.
+
+### Reasoning.Content
+
+The collapsible panel holding the steps.
+
+### Reasoning.Step
+
+A labelled row with optional indented detail.
+
+
+
+## Examples
+
+### Streaming
+
+Drive `streaming` from your transport; the trigger label and open state
+follow along.
+
+
+
+### Custom trigger label
+
+
+
+## Accessibility
+
+- Inherits Collapsible semantics: the trigger exposes `aria-expanded` and
+ toggles with Enter and Space.
+- The "Thinking…" shimmer and the panel's height tween pause under
+ `prefers-reduced-motion: reduce`.
diff --git a/apps/www/src/content/docs/ai-elements/reasoning/props.ts b/apps/www/src/content/docs/ai-elements/reasoning/props.ts
new file mode 100644
index 000000000..8ba538410
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/reasoning/props.ts
@@ -0,0 +1,41 @@
+import type React from 'react';
+
+export interface ReasoningProps {
+ /**
+ * Whether the reasoning is still being produced. While `true` the default
+ * trigger shows a shimmering "Thinking…" label and the panel auto-opens;
+ * when it flips back to `false` the panel auto-collapses — unless the user
+ * has toggled it themselves.
+ * @defaultValue false
+ */
+ streaming?: boolean;
+
+ /**
+ * How long the reasoning took, in seconds. Rendered by the default trigger
+ * label as "Worked for N seconds" once `streaming` is over.
+ */
+ duration?: number;
+
+ /** Whether the panel is open (controlled). */
+ open?: boolean;
+
+ /** Whether the panel is initially open. Defaults to `streaming`. */
+ defaultOpen?: boolean;
+
+ /** Called when the panel is opened or closed. */
+ onOpenChange?: (open: boolean) => void;
+
+ /** Custom CSS class names. */
+ className?: string;
+}
+
+export interface ReasoningStepProps {
+ /** Title row of the step, e.g. "Gathering ticket updates". */
+ label?: React.ReactNode;
+
+ /** Indented detail content below the label. */
+ children?: React.ReactNode;
+
+ /** Custom CSS class names. */
+ className?: string;
+}
diff --git a/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx b/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx
new file mode 100644
index 000000000..f257faeba
--- /dev/null
+++ b/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx
@@ -0,0 +1,84 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Reasoning } from '../reasoning';
+
+describe('Reasoning', () => {
+ const ReasoningExample = (props: {
+ streaming?: boolean;
+ duration?: number;
+ defaultOpen?: boolean;
+ }) => (
+
+
+
+
+ step detail
+
+
+
+ );
+
+ it('labels the trigger with the duration once done', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: 'Worked for 11 seconds' })
+ ).toBeInTheDocument();
+ });
+
+ it('uses the singular for one second', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: 'Worked for 1 second' })
+ ).toBeInTheDocument();
+ });
+
+ it('shows the thinking label and opens while streaming', () => {
+ render();
+ const trigger = screen.getByRole('button', { name: 'Thinking…' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('step detail')).toBeInTheDocument();
+ });
+
+ it('auto-collapses when streaming completes', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('button', { name: 'Thinking…' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+
+ rerender();
+ expect(
+ screen.getByRole('button', { name: 'Worked for 4 seconds' })
+ ).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('leaves the panel alone after a manual toggle', () => {
+ const { rerender } = render();
+ // The panel auto-opened; the user closes it deliberately.
+ const trigger = screen.getByRole('button', { name: 'Thinking…' });
+ fireEvent.click(trigger);
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+
+ // Completing the stream must not re-open or re-close the panel.
+ rerender();
+ expect(
+ screen.getByRole('button', { name: 'Worked for 2 seconds' })
+ ).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('toggles via the trigger when idle', () => {
+ render();
+ const trigger = screen.getByRole('button', {
+ name: 'Worked for 3 seconds'
+ });
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(trigger);
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('step detail')).toBeInTheDocument();
+ });
+
+ it('renders step labels', () => {
+ render();
+ expect(screen.getByText('Gathering ticket updates')).toBeInTheDocument();
+ });
+});
diff --git a/packages/raystack/components/reasoning/index.tsx b/packages/raystack/components/reasoning/index.tsx
new file mode 100644
index 000000000..ff53538d0
--- /dev/null
+++ b/packages/raystack/components/reasoning/index.tsx
@@ -0,0 +1,5 @@
+export {
+ Reasoning,
+ type ReasoningProps,
+ type ReasoningStepProps
+} from './reasoning';
diff --git a/packages/raystack/components/reasoning/reasoning.module.css b/packages/raystack/components/reasoning/reasoning.module.css
new file mode 100644
index 000000000..73f9d7316
--- /dev/null
+++ b/packages/raystack/components/reasoning/reasoning.module.css
@@ -0,0 +1,123 @@
+.reasoning {
+ min-width: 0;
+}
+
+.reasoning-trigger {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rs-space-2);
+ padding: 0;
+ border: none;
+ background: none;
+ font-family: inherit;
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ color: var(--rs-color-foreground-base-secondary);
+ cursor: pointer;
+ outline: none;
+ border-radius: var(--rs-radius-1);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .reasoning-trigger {
+ transition: var(--rs-transition-interactive);
+ }
+}
+
+.reasoning-trigger:hover {
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.reasoning-trigger:focus-visible {
+ outline: var(--rs-focus-ring);
+}
+
+.reasoning-chevron {
+ flex-shrink: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .reasoning-chevron {
+ transition: transform var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
+.reasoning-trigger[data-panel-open] .reasoning-chevron {
+ transform: rotate(90deg);
+}
+
+.reasoning-label-streaming {
+ background: linear-gradient(
+ 90deg,
+ var(--rs-color-foreground-base-tertiary) 0%,
+ var(--rs-color-foreground-base-primary) 50%,
+ var(--rs-color-foreground-base-tertiary) 100%
+ );
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .reasoning-label-streaming {
+ animation: reasoning-shimmer 2s linear infinite;
+ }
+}
+
+@keyframes reasoning-shimmer {
+ from {
+ background-position: 200% 0;
+ }
+ to {
+ background-position: -200% 0;
+ }
+}
+
+.reasoning-panel {
+ height: var(--collapsible-panel-height);
+ overflow: hidden;
+}
+
+.reasoning-panel[data-starting-style],
+.reasoning-panel[data-ending-style] {
+ height: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .reasoning-panel {
+ transition: height var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
+.reasoning-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-3);
+ padding-top: var(--rs-space-3);
+}
+
+.reasoning-step {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-2);
+}
+
+.reasoning-step-label {
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+.reasoning-step-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-2);
+ margin-left: var(--rs-space-1);
+ padding-left: var(--rs-space-4);
+ border-left: 2px solid var(--rs-color-border-base-primary);
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ color: var(--rs-color-foreground-base-secondary);
+}
diff --git a/packages/raystack/components/reasoning/reasoning.tsx b/packages/raystack/components/reasoning/reasoning.tsx
new file mode 100644
index 000000000..0f8c8c4d7
--- /dev/null
+++ b/packages/raystack/components/reasoning/reasoning.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useControlled } from '@base-ui/utils/useControlled';
+import { ChevronRightIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import {
+ ComponentProps,
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef
+} from 'react';
+import { Collapsible } from '../collapsible';
+import styles from './reasoning.module.css';
+
+interface ReasoningContextValue {
+ streaming: boolean;
+ duration?: number;
+ open: boolean;
+}
+
+const ReasoningContext = createContext(null);
+
+function useReasoningContext(part: string): ReasoningContextValue {
+ const context = useContext(ReasoningContext);
+ if (!context) {
+ throw new Error(`Reasoning.${part} must be used within `);
+ }
+ return context;
+}
+
+export interface ReasoningProps
+ extends Omit, 'onOpenChange'> {
+ /**
+ * Whether the reasoning is still being produced. While `true` the default
+ * trigger shows a shimmering "Thinking…" label and the panel auto-opens;
+ * when it flips back to `false` the panel auto-collapses — unless the user
+ * has toggled it themselves.
+ * @defaultValue false
+ */
+ streaming?: boolean;
+ /**
+ * How long the reasoning took, in seconds. Rendered by the default trigger
+ * label as "Worked for N seconds" once `streaming` is over.
+ */
+ duration?: number;
+ /** Called when the panel is opened or closed. */
+ onOpenChange?: (open: boolean) => void;
+}
+
+function ReasoningRoot({
+ className,
+ streaming = false,
+ duration,
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ children,
+ ...props
+}: ReasoningProps) {
+ const [open, setOpenUnwrapped] = useControlled({
+ controlled: openProp,
+ default: defaultOpen ?? streaming,
+ name: 'Reasoning',
+ state: 'open'
+ });
+
+ const userToggledRef = useRef(false);
+ const onOpenChangeRef = useRef(onOpenChange);
+ onOpenChangeRef.current = onOpenChange;
+
+ const setOpen = (next: boolean) => {
+ setOpenUnwrapped(next);
+ onOpenChangeRef.current?.(next);
+ };
+
+ // Auto-open while streaming and auto-collapse on completion, but hand the
+ // panel over to the user as soon as they toggle it manually.
+ const prevStreamingRef = useRef(streaming);
+ const setOpenRef = useRef(setOpen);
+ setOpenRef.current = setOpen;
+ useEffect(() => {
+ if (prevStreamingRef.current === streaming) return;
+ prevStreamingRef.current = streaming;
+ if (userToggledRef.current) return;
+ setOpenRef.current(streaming);
+ }, [streaming]);
+
+ const contextValue = useMemo(
+ () => ({ streaming, duration, open }),
+ [streaming, duration, open]
+ );
+
+ return (
+
+ {
+ userToggledRef.current = true;
+ setOpen(next);
+ }}
+ {...props}
+ >
+ {children}
+
+
+ );
+}
+
+ReasoningRoot.displayName = 'Reasoning';
+
+export interface ReasoningTriggerProps
+ extends ComponentProps {}
+
+function defaultTriggerLabel(streaming: boolean, duration?: number): ReactNode {
+ if (streaming) {
+ return (
+ Thinking…
+ );
+ }
+ if (duration != null) {
+ return `Worked for ${duration} ${duration === 1 ? 'second' : 'seconds'}`;
+ }
+ return 'Reasoning';
+}
+
+export function ReasoningTrigger({
+ className,
+ children,
+ ...props
+}: ReasoningTriggerProps) {
+ const { streaming, duration } = useReasoningContext('Trigger');
+ return (
+
+ {children ?? defaultTriggerLabel(streaming, duration)}
+
+
+ );
+}
+
+ReasoningTrigger.displayName = 'Reasoning.Trigger';
+
+export interface ReasoningContentProps
+ extends ComponentProps {}
+
+export function ReasoningContent({
+ className,
+ children,
+ ...props
+}: ReasoningContentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+ReasoningContent.displayName = 'Reasoning.Content';
+
+export interface ReasoningStepProps extends ComponentProps<'div'> {
+ /** Title row of the step, e.g. "Gathering ticket updates". */
+ label?: ReactNode;
+}
+
+export function ReasoningStep({
+ className,
+ label,
+ children,
+ ...props
+}: ReasoningStepProps) {
+ return (
+
+ {label &&
{label}
}
+ {children && (
+
{children}
+ )}
+
+ );
+}
+
+ReasoningStep.displayName = 'Reasoning.Step';
+
+export const Reasoning = Object.assign(ReasoningRoot, {
+ Trigger: ReasoningTrigger,
+ Content: ReasoningContent,
+ Step: ReasoningStep
+});
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index b500e4dbf..7d18c673f 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -88,6 +88,7 @@ export {
type PromptInputStatus
} from './components/prompt-input';
export { Radio } from './components/radio';
+export { Reasoning } from './components/reasoning';
export { ScrollArea } from './components/scroll-area';
export { Search } from './components/search';
export { Select } from './components/select';