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.
DatePickerbinds oneDate; the range preset keeps one popover for both bounds.I'll point them at
mode="range"and theonValueChangeshape.
<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
The user is asking for a date range, so a single DatePicker will not do.
DatePickerbinds oneDate.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'
<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
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 propsProp | Type | Default | Description | |
|---|---|---|---|---|
reasoning required | ChatReasoningPart | — | The reasoning part to render ({ type: 'reasoning'; text; durationMs? }). Required. | |
class | string | — | Extra classes merged onto the root (the underlying Collapsible base). | |
defaultOpen | boolean | false | Initial expanded state for uncontrolled usage. Reasoning stays collapsed by default, even while streaming. | |
formatDuration | (seconds: number) => string | (s) => `Thought for ${s}s` | Header label once settled with a durationMs, receiving whole seconds
(Math.round(durationMs / 1000)). | |
onOpenChange | (open: boolean) => void | — | Fires once per trigger-driven open transition, after the state is applied. | |
open | boolean | — | Whether the disclosure is expanded. Supports bind:open. Passing open
without bind: requires mirroring every onOpenChange back into your state. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ ReasoningDisclosure: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette. | |
reasoningLabel | string | 'Reasoning' | Header label once settled without a durationMs. | |
slotClasses | Partial<Record<ReasoningDisclosureSlots, string>> | — | Per-slot class overrides. Slots: trigger (header button), label (state label), chevron, content (markdown wrapper). | |
streaming | boolean | false | The reasoning trace is currently growing. Drives the "Thinking" label and its pulse; supplied by the caller (the part itself carries no status). | |
thinkingLabel | string | 'Thinking' | Header label while streaming. | |
unstyled | boolean | — | Strip the tv() classes of this component (trigger/label/chevron/content), of the Collapsible it renders and of the markdown body; the collapse mechanics stay. | |
urlPolicy | MarkdownUrlPolicy | — | URL policy passed through to the inner StreamingMarkdown for any links in the reasoning text (same strict default — untrusted model output). | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'class' | 'children') |
04 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
ReasoningDisclosureProps | interface | props | 0 | — | |
ChatReasoningPart | type | helper | 1 | The reasoning member of ChatMessagePart — the prop shape of ReasoningDisclosure. | |
MarkdownUrlPolicy | interface | helper | 1 | — | |
ReasoningDisclosureSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
ChatMessagePart | type | helper | 0 | One renderable segment of a message. Mirrors the shape of modern model/tool transcripts: interleaved text, reasoning, tool calls, sources and attachments — rendered in order by the ChatMessage component. | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
CitationSource | interface | helper | 0 | A cited source surfaced behind a [id] citation marker. id keys the
source to its marker; title is always shown, url / snippet are optional
and only render when present (and, for url, when the URL policy allows it). |
05 Installation
Import
import { ReasoningDisclosure } from '@urbicon-ui/blocks';
import type { ChatReasoningPart } from '@urbicon-ui/blocks';