Skip to main content
Urbicon UI
source

ReasoningDisclosureexperimental

A collapsed, muted disclosure for a model's thinking trace: a 'Thinking' label while it streams, 'Thought for Xs' once it settles.

Playground

The user wants a range, not a single date.

  • DatePicker binds one Date; the range preset keeps one popover for both bounds.

  • I'll point them at mode="range" and the onValueChange shape.

<script lang="ts">
  import { ReasoningDisclosure } from '@urbicon-ui/blocks';

  const reasoning = {
    type: 'reasoning',
    text: `The user wants a range, not a single date.

- \`DatePicker\` binds one \`Date\`; the range preset keeps one popover for both bounds.
- I'll point them at \`mode="range"\` and the \`onValueChange\` shape.`,
    durationMs: 4200
  };
</script>

<ReasoningDisclosure
  {reasoning}
/>

01 Examples

Settled trace with a duration

Pass the elapsed time as durationMs and the header reads 'Thought for Xs'. It stays collapsed until the reader expands it. Inside, the trace renders through StreamingMarkdown.

The user is asking for a date range, so a single DatePicker will not do.

  • DatePicker binds one Date.

  • For two bounds I want the range preset, keeping one popover.

I'll suggest DatePicker with mode="range" and note the onValueChange shape.

<script lang="ts">
  import { ReasoningDisclosure, type ChatReasoningPart } from '@urbicon-ui/blocks';

  // A finished reasoning trace. `durationMs` drives the "Thought for Xs" label;
  // the body stays collapsed by default so it never crowds the answer.
  const reasoning: ChatReasoningPart = {
    type: 'reasoning',
    durationMs: 4200,
    text: `The user is asking for a **date range**, so a single DatePicker will not do.

- \`DatePicker\` binds one \`Date\`.
- For two bounds I want the range preset, keeping one popover.

I'll suggest \`DatePicker\` with \`mode="range"\` and note the \`onValueChange\` shape.`
  };
</script>

<ReasoningDisclosure {reasoning} />

Streaming: the label pulses 'Thinking'

While streaming is set, the header shows 'Thinking' and the trace grows as text arrives. When the stream ends, set streaming to false and pass a durationMs, and the label settles to 'Thought for Xs'.
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { Button, ReasoningDisclosure, type ChatReasoningPart } from '@urbicon-ui/blocks';

  const full = `Let me work through the layout.

First, the sidebar is fixed-width on desktop but collapses under \`md\`. That points at **SidebarLayout**, not a hand-rolled grid.

Then the main column needs its own scroll region so the header can pin. \`fit="viewport"\` on the content handles that.`;

  // The caller drives `streaming` and grows `text` from the transport; while
  // streaming the header pulses "Thinking", then settles to the duration.
  let reasoning = $state<ChatReasoningPart>({ type: 'reasoning', text: '' });
  let streaming = $state(false);
  let timer: ReturnType<typeof setInterval> | undefined;

  function stream() {
    clearInterval(timer);
    const startedAt = Date.now();
    let i = 0;
    reasoning = { type: 'reasoning', text: '' };
    streaming = true;
    timer = setInterval(() => {
      i += 4;
      if (i >= full.length) {
        clearInterval(timer);
        streaming = false;
        reasoning = { type: 'reasoning', text: full, durationMs: Date.now() - startedAt };
        return;
      }
      reasoning = { ...reasoning, text: full.slice(0, i) };
    }, 40);
  }

  onDestroy(() => clearInterval(timer));
</script>

<div class="space-y-3">
  <ReasoningDisclosure {reasoning} {streaming} defaultOpen />
  <Button size="sm" variant="outlined" onclick={stream}>Stream reasoning</Button>
</div>

Localized header

Every string in the header is a prop: formatDuration formats the seconds, thinkingLabel and reasoningLabel cover the streaming and no-duration cases. Override all three to localize; here in German.

Kurz abgewogen: Toast fuer die Bestaetigung, kein Dialog — die Aktion ist nicht destruktiv.

<script lang="ts">
  import { ReasoningDisclosure, type ChatReasoningPart } from '@urbicon-ui/blocks';

  const reasoning: ChatReasoningPart = {
    type: 'reasoning',
    durationMs: 3000,
    text: 'Kurz abgewogen: `Toast` fuer die Bestaetigung, kein `Dialog` — die Aktion ist nicht destruktiv.'
  };

  // formatDuration receives whole seconds; thinkingLabel / reasoningLabel cover
  // the streaming and no-duration cases. Together they fully localize the header.
  const formatDuration = (s: number) => `${s} Sekunden nachgedacht`;
</script>

<ReasoningDisclosure
  {reasoning}
  {formatDuration}
  thinkingLabel="Denke nach"
  reasoningLabel="Überlegung"
/>

02 Accessibility

Disclosure semantics

The header is a real <button> carrying aria-expanded and aria-controls for the trace region, the standard Collapsible contract. Tab to reach it, Enter or Space to toggle. Focus rings use focus-visible:.

Reduced motion

The "Thinking" pulse is guarded by motion-reduce:animate-none, so readers who set prefers-reduced-motion get the same label without the animation.

Untrusted output

The trace is model output, so it renders through StreamingMarkdown, not {@html}, and its links follow the same strict urlPolicy.

03 API Reference

14 props
14 props 1 required
Prop
Type
Default
Description

04 Types

Local type definitions used by this component.

7 types
Name
Kind
Category
Used by
Description

05 Installation

Import

import { ReasoningDisclosure } from '@urbicon-ui/blocks';
import type { ChatReasoningPart } from '@urbicon-ui/blocks';