AI

Reasoning Block

A collapsible thinking panel whose open/closed state is driven by the stream's lifecycle — and permanently yields to the reader the moment they touch it.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Brain, ChevronDown, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"

/** Where the reasoning run is in its lifecycle. `"streaming"` = tokens are still arriving. */
export type ReasoningStatus = "streaming" | "done"

export interface ReasoningSummaryContext {
  /** True while `status === "streaming"`. */
  streaming: boolean
  /**
   * Total run time once finished. While streaming it is the elapsed time — but only

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/reasoning-block.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ReasoningBlock" component (lucide-react
icons only). It is the collapsible "thinking" panel that sits above an
assistant's answer: one summary line when closed ("Thought for 12s"), the full
reasoning trace when open. The interesting part is not the disclosure — it is
who decides whether it is open.

Contract
- forwardRef<HTMLDivElement> extending Omit<React.HTMLAttributes<HTMLDivElement>,
  "children">; remaining props spread onto the root.
- children: the reasoning itself — a plain string, or whatever renders it
  (a token-streaming component, a markdown renderer). Never import that renderer;
  keep it a slot so the block and the text renderer stay independent.
- status?: "streaming" | "done" (default "done").
- startedAt?, endedAt?: epoch ms, supplied by the caller. The component must NEVER
  call Date.now() during render — the server and the first client frame have to
  produce the same string.
- liveDuration?: boolean (default false), defaultExpanded?: boolean,
  autoCollapse?: boolean (default true), maxHeight?: number | "none" (default 240).
- renderSummary?: ({ streaming, durationMs }) => ReactNode — also replaces the
  toggle's accessible name.
- announceDone?: (durationMs: number | null) => string — the single screen-reader
  sentence fired when the run ends; return "" for silence.
- onExpandedChange?: (expanded, { source: "user" | "auto" }) => void.

Behavior — the expansion state machine (this is the component)
- Initial state: defaultExpanded ?? (status === "streaming").
- On a real status transition into "streaming": open it. On a transition out of
  "streaming": close it, if autoCollapse.
- The instant the user clicks the toggle, set a sticky "user took over" flag and
  never run either automatic branch again for the life of that instance. A reader
  who opens the panel mid-stream must still be looking at it after the stream
  ends — having something you deliberately expanded snap shut a second later is
  the exact failure this component exists to prevent. To hand control back
  (a new run in the same slot), remount with a new key.
- Do the transition detection as render-phase adjust-state (compare a lastStatus
  state value with the incoming prop and setState during render), not in an
  effect: the panel must be open on the very frame the stream starts, and effects
  paint one wrong frame first.
- Empty reasoning: if the run is finished and children are empty (null/undefined/
  false/whitespace-only string/array of those), render null — no header, no empty
  shell. A run that is still streaming renders even with nothing in it yet,
  because "no tokens for the first 300ms" is normal. Check emptiness with a
  short-circuiting recursion so a 200-chunk array costs one comparison, not 200.

Behavior — duration
- durationMs = endedAt − startedAt (clamped at 0, null if either is missing).
- While streaming, show "Thinking…" — not a number. A number that only looks
  precise is worse than a word. With liveDuration, tick whole seconds, and derive
  every value from `Date.now() − startedAt`; never `prev + 1`. A background tab
  throttles timers to about one wake-up a minute, and a self-incrementing counter
  comes back under-counting by exactly the time the user was away. Schedule the
  next tick at the next whole-second boundary (1000 − elapsed % 1000) so nothing
  drifts and no second renders twice, recompute immediately on visibilitychange,
  and start the first tick from a setTimeout — reading the clock in the effect
  body would put a number in the first client frame that the server never wrote.
  Hide the live counter below one second so it never reads "0.0s".
- Format: <1s → one decimal ("0.4s"), <60s → "12s", otherwise "1m 4s".

Behavior — growth, scrolling, and the reader's position
- The panel is capped (maxHeight, default 240px) and scrolls internally rather
  than growing without bound. A capped panel keeps the answer a fixed distance
  below the header whether the trace is 200 characters or 200 000; free growth
  pushes the answer off-screen exactly when the user wants to read it. Measured
  on 30 blocks × 40 000 chars: 9 030px of document with the cap, 206 940px
  without.
- While streaming, pin the panel to its tail on every content commit (a layout
  effect, isomorphic so the server never runs it). On every scroll event,
  recompute one boolean: "am I within ~8px of the bottom?" Scrolling up pauses
  the follow; scrolling back to the bottom resumes it. That is the whole takeover
  contract — the pin lands exactly at the bottom, so it re-arms itself instead of
  fighting. Keep the flag in a ref: it changes on every scroll event and must not
  re-render.
- Stop pinning once the run is over. After that the scroll position belongs to
  the reader: a finished block reopens where they left it, and one that never
  streamed opens at the top.
- Collapse must not lose that position, so use two nested boxes: the outer one is
  what the grid animation drives to zero height, the inner one is the scroller and
  keeps its own height (and therefore its own scrollTop) behind the clip.

Behavior — screen readers
- The reasoning text is NOT a live region. A stream that announces every chunk
  makes a screen reader unusable. Render exactly one sr-only role="status"
  aria-live="polite" line, always present in the DOM, empty during the stream,
  written once when the run ends: "Finished thinking after 12s."
- Only announce on an observed streaming → done transition. Mounting an already
  finished block must stay silent, otherwise scrolling back through a transcript
  announces every reasoning block in it.
- Collapsed, the panel is aria-hidden and inert, so it is neither read nor
  tabbable — a zero-height grid row alone still leaves an invisible keyboard trap.

Rendering & styling
- Semantic tokens only: rounded-lg border bg-muted/30 shell, hover:bg-muted/60 on
  the trigger, text-muted-foreground for the icons and the reasoning body,
  focus-visible:ring-2 focus-visible:ring-ring on the trigger. cn() merges the
  consumer className onto the root.
- The trigger is a real <button type="button"> with aria-expanded and
  aria-controls; the panel carries the matching id, role="group" and
  aria-labelledby. Use group, not region: a transcript holds dozens of these and
  dozens of landmarks would drown landmark navigation.
- Header geometry is identical in both states — same size-4 icon box (a spinner
  while streaming, a brain when done), same text line, same padding — so
  expanding or collapsing never moves a single pixel of what is above the block.
  Only the panel below it grows. The summary line is min-w-0 flex-1 truncate and
  the body is [overflow-wrap:anywhere], so neither a long label nor an
  unbreakable 400-character token can widen the page.
- Animate grid-template-rows between 0fr and 1fr with an overflow-hidden child,
  200ms; the chevron rotates 180°. Both carry motion-reduce:transition-none and
  the spinner motion-reduce:animate-none — with motion off the panel still opens
  and closes, instantly.
- Mount children lazily: keep them out of the DOM until the block is first opened
  (or is streaming). Measured on 30 blocks × 40 000 chars, a forced reflow costs
  8.8ms with the text mounted and 0.6ms without. Mounting happens in the same
  render pass that opens the panel, so no frame ever shows an open-but-empty box.

Customization levers
- Automation policy: autoCollapse=false keeps finished reasoning open (good for a
  debugging/eval surface where the trace is the product); defaultExpanded pins the
  first-paint state; the "sticky override" is one boolean — drop it if you want a
  surface where automation always wins, but then read the note above first.
- Height: maxHeight is the single geometry knob. "none" lets the panel grow with
  the trace (fine for a dedicated trace viewer, bad inside a chat transcript).
- Timing display: liveDuration for a visible counter; renderSummary for a
  completely different header (a token count, a model name, a per-locale
  duration through Intl.NumberFormat with an explicit locale).
- Voice and localisation: renderSummary and announceDone are the two strings a
  screen reader hears; keep announceDone one short sentence, since it fires while
  the answer below is already arriving.
- Density and skin: the px-3 py-2 header, rounded-lg radius and bg-muted/30 shell
  are the visual knobs; swap bg-muted/30 for bg-card to make the block read as a
  raised card instead of an inset one.
- Content: children is a slot. Pass a token renderer, a markdown renderer, or a
  list of numbered thoughts — the block never parses what it shows. Note that the
  slot is unmounted until first opened, so anything with side effects inside it
  should tolerate a late mount (or be opened with defaultExpanded).

Concepts

  • User intent outranks automation — the auto-open and auto-collapse branches are guarded by one sticky flag set on the first manual toggle. Expanding a trace mid-stream and having it snap shut when the stream ends is the single most annoying thing this component could do, so the flag never resets; a new run in the same slot gets a fresh instance via key.
  • Render-phase adjust-state — the status transition is detected by comparing a lastStatus state value with the incoming prop during render, so the panel is already open on the frame the stream starts. An effect would paint one collapsed frame first, and setState inside an effect body is exactly what the project's lint rule forbids.
  • Timings are props, never a clock readstartedAt / endedAt come from the caller, so renderToStaticMarkup and the first client frame produce identical markup. The optional live counter recomputes from startedAt on every tick instead of incrementing, which is why a tab that was throttled for 37 seconds comes back reading 40s rather than 4s.
  • Follow-the-tail with takeover — one boolean, recomputed on every scroll event, decides whether the next token pins the panel to its bottom. Scrolling up pauses it, returning to the bottom re-arms it, and the pin itself lands at the bottom, so it can never fight the reader.
  • Stable header geometry — the summary row renders the same box in both states, so everything above the block keeps its exact top through an expand/collapse (measured delta: 0.000px). Only what is below it moves.
  • Announce once, not per token — the reasoning text is deliberately not a live region; a single role="status" line stays empty for the whole stream and is written exactly once at the end. 200 chunks produce one announcement.
  • Nothing means nothing — a finished run with empty reasoning renders no DOM at all, rather than an empty disclosure shell with a chevron that opens onto nothing.

On This Page