Reasoning

A collapsible reasoning disclosure that tracks streaming state and elapsed duration.

Planning the approach

Check the existing layout, then decide which axis needs centering.

Verifying

Confirm the element centers both horizontally and vertically.

"use client";

import { Reasoning } from "@intentface/chat/reasoning";
import { Brain, ChevronDown } from "@keyline-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 rounded-full font-medium text-[13px] text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:text-zinc-400 dark:hover:text-zinc-100">
        <Brain className="size-[15px]" />
        Thought for a few seconds
        <ChevronDown className="size-3 transition-transform group-data-closed:rotate-180" />
      </Reasoning.Trigger>
      <Reasoning.Content className="flex flex-col gap-3 pl-6 text-[13px]">
        {SECTIONS.map((section) => (
          <div key={section.header} className="flex flex-col gap-0.5">
            <span className="font-medium text-zinc-900 dark:text-zinc-100">{section.header}</span>
            <p className="text-zinc-500 leading-5 dark:text-zinc-400">{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 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.

Nothing yet.
"use client";

import { Reasoning, useReasoning } from "@intentface/chat/reasoning";
import { Brain, ChevronDown } from "@keyline-icons/react";
import { useEffect, useRef, useState } from "react";
import "./streaming.css";

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">
      <Reasoning.Root isStreaming={streaming} defaultOpen className="flex flex-col gap-1">
        <TriggerLabel />
        <Reasoning.Content className="flex flex-col gap-2 pl-6 text-[13px] text-zinc-500 leading-5 dark:text-zinc-400">
          {shown.length === 0 ? (
            <span className="text-zinc-400 dark:text-zinc-500">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 bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
        >
          {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-full font-medium text-[13px] text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:text-zinc-400 dark:hover:text-zinc-100">
      <Brain className="size-[15px]" />
      <span className={isStreaming ? "animate-pulse" : undefined}>
        {isStreaming
          ? "Thinking…"
          : duration === undefined
            ? "Reasoning"
            : `Thought for ${duration}s`}
      </span>
      <ChevronDown className="size-3 transition-transform group-data-closed:rotate-180" />
    </Reasoning.Trigger>
  );
};

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) 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.

PropTypeDefaultDetails
isStreamingbooleanfalse
durationnumber—
defaultOpenbooleanfalse
openboolean—
onOpenChange(open: boolean) => void—
AttributeDescription
data-reasoning
The disclosure root.
data-streaming
Present while isStreaming is true.
data-open
Present while open.
data-closed
Present while closed.

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.

PropTypeDefaultDetails
childrenReactNode—
AttributeDescription
data-reasoning-trigger
The toggle button.
data-open
Present while open.
data-closed
Present while closed.

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.

PropTypeDefaultDetails
childrenstring | string[](required)
keepMountedbooleanfalse
AttributeDescription
data-reasoning-content
The panel.
data-open
Present while open.
data-closed
Present while closed.
data-starting-style
Present on the first open frame (enter transition).
data-ending-style
Present while the exit animation runs.
CSS variableDescription
--panel-height
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.measured px

useReasoning

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

PropTypeDefaultDetails
isStreamingboolean—
isOpenboolean—
setIsOpen(open: boolean) => void—
durationnumber | undefined—