---
title: Reasoning
description: A collapsible reasoning disclosure that tracks streaming state and elapsed duration.
source: reasoning
---

```tsx title="primitives/reasoning/demos/basic.tsx"
"use client";

import { Reasoning } from "@intentface/chat/reasoning";
import { IconBrain, IconChevronDown } from "@tabler/icons-react";

// A completed reasoning block. The parts ship no copy and no layout — the
// trigger label and the section structure below are both yours.
const SECTIONS = [
  {
    header: "Planning the approach",
    body: "Check the existing layout, then decide which axis needs centering.",
  },
  {
    header: "Verifying",
    body: "Confirm the element centers both horizontally and vertically.",
  },
];

export const Basic = () => (
  <div className="w-full max-w-xl">
    <Reasoning.Root defaultOpen className="flex flex-col gap-1">
      <Reasoning.Trigger className="group flex w-fit cursor-pointer items-center gap-2 text-sm text-[#686868] transition-colors hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc]">
        <IconBrain className="size-4" />
        Thought for a few seconds
        <IconChevronDown className="size-3 transition-transform group-data-closed:rotate-180" />
      </Reasoning.Trigger>
      <Reasoning.Content className="flex flex-col gap-3 pl-6 text-sm">
        {SECTIONS.map((section) => (
          <div key={section.header} className="flex flex-col gap-0.5">
            <span className="font-medium text-[#1a1a1a] dark:text-[#fcfcfc]">{section.header}</span>
            <p className="leading-[1.7] text-[#686868] dark:text-[#9b9b9b]">{section.body}</p>
          </div>
        ))}
      </Reasoning.Content>
    </Reasoning.Root>
  </div>
);
```

## Usage guidelines

- **Chain-of-thought disclosure** — a collapsible block for a model's thinking.
- **Live label** — shimmers "Thinking…" while streaming and settles to "Thought for Ns" when it stops; the duration is tracked for you.
- **Sectioned content** — bold `**Header**` lines split the text into labelled sections, rendered as markdown.
- **State via `useReasoning`** — read streaming, open, and duration from anywhere inside.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

```tsx
<Reasoning.Root isStreaming={isStreaming}>
  <Reasoning.Trigger />
  <Reasoning.Content>{reasoningText}</Reasoning.Content>
</Reasoning.Root>
```

Driven from a streaming message, passing the reasoning parts' text:

```tsx
<Reasoning.Root isStreaming={isLast && isStreaming} defaultOpen={false}>
  <Reasoning.Trigger label={headers} />
  <Reasoning.Content>{texts}</Reasoning.Content>
</Reasoning.Root>
```

## Examples

### Tracking the streaming state

`isStreaming` is the only input. While it is true the root carries
`data-streaming` and `aria-busy`; the moment it flips false the elapsed time is
captured and handed back as `duration`, so the trigger's label changes without
you timing anything.

```tsx title="primitives/reasoning/demos/streaming.tsx"
"use client";

import { Reasoning, useReasoning } from "@intentface/chat/reasoning";
import { IconBrain, IconChevronDown } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";

const SECTIONS = [
  "Check the existing layout, then decide which axis needs centering.",
  "The parent is already a flex row, so only the cross axis is missing.",
  "Confirm the element centers both horizontally and vertically.",
];

/*
 * The part of Reasoning the hero demo cannot show: what happens while the
 * model is still thinking.
 *
 * `isStreaming` is the only input. The root carries `data-streaming` and
 * `aria-busy` while it is true, and the moment it flips false the elapsed time
 * is captured and handed back through `useReasoning().duration` — so the
 * trigger's label changes without the consumer timing anything.
 *
 * The text arrives on a timer here rather than from a model; the primitive
 * neither knows nor cares where it comes from.
 */
export const Streaming = () => {
  const [streaming, setStreaming] = useState(false);
  const [shown, setShown] = useState<string[]>([]);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const replay = () => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
    setShown([]);
    setStreaming(true);

    SECTIONS.forEach((section, index) => {
      timers.current.push(
        setTimeout(() => setShown((current) => [...current, section]), (index + 1) * 700),
      );
    });
    timers.current.push(setTimeout(() => setStreaming(false), (SECTIONS.length + 1) * 700));
  };

  return (
    <div className="reasoning-demo flex w-full max-w-xl flex-col gap-3">
      <PanelTransition />

      <Reasoning.Root isStreaming={streaming} defaultOpen className="flex flex-col gap-1">
        <TriggerLabel />
        <Reasoning.Content className="flex flex-col gap-2 pl-6 text-[#686868] text-sm dark:text-[#9b9b9b]">
          {shown.length === 0 ? (
            <span className="text-[#949494] dark:text-[#6f6f6f]">Nothing yet.</span>
          ) : (
            shown.map((section) => <p key={section}>{section}</p>)
          )}
        </Reasoning.Content>
      </Reasoning.Root>

      <div className="flex justify-center">
        <button
          type="button"
          onClick={replay}
          disabled={streaming}
          className="h-8 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] disabled:cursor-default disabled:opacity-40 dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323]"
        >
          {streaming ? "Thinking…" : "Replay"}
        </button>
      </div>
    </div>
  );
};

/**
 * The trigger ships no copy. `useReasoning` is how the label learns what to
 * say: `isStreaming` while it runs, then `duration` once the package has
 * captured it.
 */
const TriggerLabel = () => {
  const { isStreaming, duration } = useReasoning();

  return (
    <Reasoning.Trigger className="group flex w-fit cursor-pointer items-center gap-2 rounded text-[#686868] text-sm transition-colors hover:text-[#1a1a1a] focus-visible:outline-2 focus-visible:outline-[#1a1a1a] focus-visible:outline-offset-2 dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]">
      <IconBrain className="size-4" />
      <span className={isStreaming ? "animate-pulse" : undefined}>
        {isStreaming
          ? "Thinking…"
          : duration === undefined
            ? "Reasoning"
            : `Thought for ${duration}s`}
      </span>
      <IconChevronDown className="size-3 transition-transform group-data-closed:rotate-180" />
    </Reasoning.Trigger>
  );
};

/*
 * `--panel-height` is published only while a transition runs, and released once
 * the panel settles open. That release is what lets the open panel keep growing
 * as sections stream in: with the property gone the declaration is invalid at
 * computed-value time and `height` lands back on `auto`.
 */
const PanelTransition = () => (
  <style>{`
.reasoning-demo [data-reasoning-content] {
  height: var(--panel-height);
  overflow: hidden;
  transition: height 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms ease-out;
}
.reasoning-demo [data-reasoning-content][data-starting-style],
.reasoning-demo [data-reasoning-content][data-ending-style] { height: 0; opacity: 0; }
`}</style>
);
```

The panel animates through `--panel-height`, which is published only while a
transition runs and released once it settles open. That release is what lets an
open panel keep growing as sections stream into it.

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/styling)) and emits a bespoke part attribute (`data-<part>`) unless noted.

### Reasoning

The disclosure root. Tracks how long `isStreaming` stayed true and hands the
elapsed seconds back through `useReasoning`, so a label can change without the
consumer timing anything. Renders a `<div>` element.

export const rootProps = [
  { name: "isStreaming", type: "boolean", default: "false", description: "While true the trigger shimmers; the streamed duration is captured when it flips false." },
  { name: "duration", type: "number", description: "Override the tracked streaming duration (seconds)." },
  { name: "defaultOpen", type: "boolean", default: "false", description: "Uncontrolled initial open state." },
  { name: "open", type: "boolean", description: "Controlled open state." },
  { name: "onOpenChange", type: "(open: boolean) => void", description: "Fires on toggle." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-reasoning", description: "The disclosure root." },
  { attribute: "data-streaming", description: "Present while isStreaming is true." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
];

<AttributesTable rows={rootAttrs} />

### Reasoning.Trigger

The toggle. Ships no copy: supply the label as children, and read `isStreaming`
and `duration` from `useReasoning` if it should change while the model is
thinking. Renders a `<button>` element.

export const triggerProps = [
  { name: "children", type: "ReactNode", description: "The trigger content — a label, a chevron, whatever you need." },
];

<PropsTable rows={triggerProps} />

export const triggerAttrs = [
  { attribute: "data-reasoning-trigger", description: "The toggle button." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
];

<AttributesTable rows={triggerAttrs} />

### Reasoning.Content

The collapsible panel. Publishes its measured height while a transition runs
and releases it once settled open, so a panel that is open keeps growing as text
streams in. Renders a `<div>` element.

export const contentProps = [
  { name: "children", type: "string | string[]", default: "(required)", description: "Reasoning text; bold headers split it into sections." },
  { name: "keepMounted", type: "boolean", default: "false", description: "Keep the panel in the DOM (hidden) when closed." },
];

<PropsTable rows={contentProps} />

export const contentAttrs = [
  { attribute: "data-reasoning-content", description: "The panel." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
  { attribute: "data-starting-style", description: "Present on the first open frame (enter transition)." },
  { attribute: "data-ending-style", description: "Present while the exit animation runs." },
  { attribute: "--panel-height", values: "measured px", description: "The content's natural height, published only while the open or close transition runs so a height transition has a number to animate from. Deliberately released once it settles open, which makes `height: var(--panel-height)` fall back to `auto` so the open panel tracks reasoning text as it streams in." },
];

<AttributesTable rows={contentAttrs} />

## useReasoning

Read the disclosure state from anywhere inside `<Reasoning.Root>`:

export const hookMembers = [
  { name: "isStreaming", type: "boolean", description: "Whether reasoning is still streaming in." },
  { name: "isOpen", type: "boolean", description: "Whether the panel is open." },
  { name: "setIsOpen", type: "(open: boolean) => void", description: "Toggle the panel." },
  { name: "duration", type: "number | undefined", description: "Tracked streaming duration in seconds." },
];

<PropsTable rows={hookMembers} />
