Skip to content
AI & Chat primitive

Reasoning Panel

The "Thought for 12s" disclosure, built on a native details element — so it keeps its state without JavaScript, needs no ARIA at all, and renders on the server in the same pass as the answer above it.

110 lineslucide-reactAdded 14 Sept 2026
  • reasoning
  • thinking
  • trace
  • ai
  • disclosure
  • details

What's included

  • components/ui/reasoning-panel.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add reasoning-panel

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Thought for 12s
  1. The rollback happened at 02:14, so start from the release controller log.
  2. Two consecutive health checks failed — that is the revert threshold.
  3. Check whether the failing check was the new one added in this release.

Source

One file. Create components/ui/reasoning-panel.tsx and paste.

components/ui/reasoning-panel.tsx
/**
 * <ReasoningPanel> — the "Thought for 12s" disclosure above an answer.
 *
 * **It is a real `<details>`, and that is the whole design.** A reasoning
 * trace is the textbook case for the native element: a disclosure with no
 * requirement to be controlled from anywhere else. Using it means the open
 * state survives with JavaScript disabled, Enter and Space work without a
 * handler, the browser exposes it as a disclosure to assistive tech with
 * no ARIA at all, and — the reason it matters here — **this component has
 * no state, so it needs no `'use client'`**. It renders on the server, in
 * the same pass as the message it sits above.
 *
 * Its sibling `<ToolCall>` deliberately does the opposite, with an
 * `aria-expanded` button, because that one does need to be opened from
 * outside. Two disclosures, two mechanisms, each for a stated reason.
 *
 * **The steps are an `<ol>`.** The order is the meaning — step three
 * follows from step two — and an unordered list throws that away.
 *
 * **The chevron points down, not right.** A rotating right-chevron is the
 * usual choice and it drags an RTL problem in with it: the closed state has
 * to mirror, the open state must not, and two rotate utilities on one
 * element fight. Down-to-up rotates on the axis the reading direction has
 * no opinion about, so there is nothing to mirror.
 *
 * **Nothing here is a live region.** While a trace is streaming, the tokens
 * are already being announced by the thread's own `role="log"`. A second
 * live region over the same text reads it twice.
 */

import * as React from 'react'
import { ChevronDown, Sparkles } from 'lucide-react'

export interface ReasoningPanelProps {
  /** Seconds spent thinking. Rendered as "Thought for 12s". */
  seconds?: number
  /** Replaces the generated summary text entirely. */
  label?: string
  /** Rendered as an ordered list. Use `children` for richer content. */
  steps?: React.ReactNode[]
  /** Dims the label and marks the trace as still arriving. */
  streaming?: boolean
  defaultOpen?: boolean
  className?: string
  children?: React.ReactNode
}

export function ReasoningPanel({
  seconds,
  label,
  steps,
  streaming = false,
  defaultOpen = false,
  className = '',
  children,
}: ReasoningPanelProps) {
  const summary =
    label ??
    (streaming
      ? 'Thinking…'
      : seconds !== undefined
        ? `Thought for ${seconds}s`
        : 'Reasoning')

  return (
    <details
      open={defaultOpen || undefined}
      className={['group rounded-lg border border-border bg-muted/40', className]
        .filter(Boolean)
        .join(' ')}
    >
      <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary [&::-webkit-details-marker]:hidden">
        <Sparkles
          aria-hidden="true"
          className={[
            'size-3.5 shrink-0',
            streaming ? 'motion-safe:animate-pulse' : '',
          ]
            .filter(Boolean)
            .join(' ')}
        />
        <span className="flex-1 font-medium">{summary}</span>
        <ChevronDown
          aria-hidden="true"
          className="size-4 shrink-0 transition-transform group-open:rotate-180"
        />
      </summary>

      <div className="border-t border-border px-3 py-2.5 text-sm leading-relaxed text-muted-foreground">
        {steps && steps.length > 0 ? (
          <ol className="space-y-2">
            {steps.map((step, index) => (
              <li key={index} className="flex gap-2.5">
                <span
                  aria-hidden="true"
                  className="mt-0.5 grid size-4 shrink-0 place-items-center rounded-full bg-border text-[10px] font-medium tabular-nums text-muted-foreground"
                >
                  {index + 1}
                </span>
                <span className="min-w-0">{step}</span>
              </li>
            ))}
          </ol>
        ) : null}
        {children}
      </div>
    </details>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
secondsSeconds spent thinking. Rendered as "Thought for 12s".number—
labelReplaces the generated summary text entirely.string—
stepsRendered as an ordered list. Use `children` for richer content.React.ReactNode[]—
streamingDims the label and marks the trace as still arriving.booleanfalse
defaultOpenbooleanfalse
childrenReact.ReactNode—
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More ai & chat

View all
Open the full page for this primitive

Message Bubble

One turn in a thread, with the part every chat UI leaves out: the speaker is in the text rather than implied by which side the bubble sits on, so a screen reader hears a conversation instead of an unattributed monologue.

AI & Chat178 linesNo deps
Open the full page for this primitive

Prompt Input

The composer, reduced to the four things that are actually hard: Enter that does not fire mid-IME-composition, one submit path for button and keyboard alike, stop as a separate button from send, and a row cap measured from the real line height.

AI & Chat177 lines1 dep
Open the full page for this primitive

Tool Call

A single tool invocation that opens into its arguments and result — status carried by a glyph and a word rather than a colour, arguments as a definition list, and a duration formatted arithmetically so it cannot drift at hydration.

AI & Chat202 lines1 dep