Skip to content
edmi-ui

Reasoning

Collapsible thinking block that streams while the model reasons and closes itself when done.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Built on collapsible and ai-shimmer. The content renders markdown with Streamdown; React consumers add the @source line described on the ai-message page.

Terminal window
npx shadcn@latest add @edmi-ui/ai-reasoning
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-reasoning
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-reasoning
Terminal window
bunx shadcn@latest add @edmi-ui/ai-reasoning

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-reasoning
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-reasoning
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-reasoning
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-reasoning

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-reasoning.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-reasoning.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-reasoning.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-reasoning.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai/reasoning";
<div className="flex w-full max-w-lg flex-col gap-6">
<Reasoning>
<ReasoningTrigger />
<ReasoningContent></ReasoningContent>
</Reasoning>
<Reasoning defaultOpen={false} duration={6}>
<ReasoningTrigger />
<ReasoningContent></ReasoningContent>
</Reasoning>
</div>
<script setup lang="ts">
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai/reasoning";
</script>
<template>
<div class="flex w-full max-w-lg flex-col gap-6">
<Reasoning>
<ReasoningTrigger />
<ReasoningContent />
</Reasoning>
<Reasoning :default-open="false" :duration="6">
<ReasoningTrigger />
<ReasoningContent />
</Reasoning>
</div>
</template>
<script lang="ts">
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "$lib/components/ai/reasoning";
</script>
<div class="flex w-full max-w-lg flex-col gap-6">
<Reasoning>
<ReasoningTrigger />
<ReasoningContent />
</Reasoning>
<Reasoning defaultOpen={false} duration={6}>
<ReasoningTrigger />
<ReasoningContent />
</Reasoning>
</div>
registry/components/ai/reasoning.tsx
"use client";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { BrainIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Shimmer } from "@/registry/edmi/components/ai/shimmer";
import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = Omit<
ComponentProps<typeof Collapsible>,
"open" | "defaultOpen" | "onOpenChange"
> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen],
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen],
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
data-slot="ai-reasoning"
className={cn("not-prose", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
},
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return (
<Shimmer as="span" duration={1}>
Thinking…
</Shimmer>
);
}
if (duration === undefined) {
return <span>Thought for a few seconds</span>;
}
return <span>Thought for {duration} seconds</span>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
data-slot="ai-reasoning-trigger"
className={cn(
"flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon
className="size-4"
/>
{getThinkingMessage(isStreaming, duration)}
<CaretDownIcon
className={cn(
"ml-auto size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</>
)}
</CollapsibleTrigger>
);
},
);
export type ReasoningContentProps = Omit<
ComponentProps<typeof CollapsibleContent>,
"children"
> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
data-slot="ai-reasoning-content"
className={cn(
"h-(--collapsible-panel-height) overflow-hidden outline-none transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0",
className,
)}
{...props}
>
<div className="mt-2.5 ml-[7px] border-l-2 border-border pl-[23px] text-[13.5px] leading-[1.6] text-muted-foreground">
<Streamdown
className="[&_p]:my-0 [&_p+*]:mt-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
plugins={streamdownPlugins}
>
{children}
</Streamdown>
</div>
</CollapsibleContent>
),
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
registry/components/ai/reasoning/Reasoning.vue
<script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue"
import { useVModel } from "@vueuse/core"
import { computed, onBeforeUnmount, provide, ref, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible } from "@/registry/edmi/ui/collapsible"
import { ReasoningKey } from "./context"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
/** Seconds. Measured automatically when `isStreaming` flips to false. */
duration?: number
}>(), {
isStreaming: false,
open: undefined,
defaultOpen: undefined,
duration: undefined,
})
const emit = defineEmits<{
(e: "update:open", value: boolean): void
(e: "update:duration", value: number): void
}>()
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
const isOpen = useVModel(props, "open", emit, {
defaultValue: props.defaultOpen ?? props.isStreaming,
passive: true,
}) as Ref<boolean>
const internalDuration = ref<number | undefined>(props.duration)
watch(() => props.duration, (value) => {
internalDuration.value = value
})
const hasEverStreamed = ref(props.isStreaming)
const hasAutoClosed = ref(false)
let startTime: number | null = null
// Track when streaming starts and compute the duration when it ends; auto-open on start unless
// `defaultOpen` was explicitly false.
watch(() => props.isStreaming, (streaming) => {
if (streaming) {
hasEverStreamed.value = true
if (!isOpen.value && props.defaultOpen !== false)
isOpen.value = true
if (startTime === null)
startTime = Date.now()
}
else if (startTime !== null) {
const seconds = Math.ceil((Date.now() - startTime) / MS_IN_S)
internalDuration.value = seconds
emit("update:duration", seconds)
startTime = null
}
}, { immediate: true })
// Auto-close once when streaming ends (only if it ever streamed).
let timer: ReturnType<typeof setTimeout> | undefined
watch([() => props.isStreaming, isOpen, hasAutoClosed], () => {
clearTimeout(timer)
if (hasEverStreamed.value && !props.isStreaming && isOpen.value && !hasAutoClosed.value) {
timer = setTimeout(() => {
isOpen.value = false
hasAutoClosed.value = true
}, AUTO_CLOSE_DELAY)
}
}, { immediate: true })
onBeforeUnmount(() => clearTimeout(timer))
provide(ReasoningKey, {
isStreaming: computed(() => props.isStreaming),
isOpen,
setIsOpen: (value: boolean) => {
isOpen.value = value
},
duration: computed(() => internalDuration.value),
})
</script>
<template>
<Collapsible
v-model:open="isOpen"
data-slot="ai-reasoning"
:class="cn('not-prose', props.class)"
>
<slot />
</Collapsible>
</template>
registry/components/ai/reasoning/ReasoningContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, useSlots } from "vue"
import { Markdown } from "vue-stream-markdown"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
import "vue-stream-markdown/index.css"
const props = defineProps<{
class?: HTMLAttributes["class"]
/** Markdown source; the default slot text works too. */
content?: string
}>()
const slots = useSlots()
const slotContent = computed<string | undefined>(() => {
const nodes = slots.default?.()
if (!Array.isArray(nodes))
return undefined
let text = ""
for (const node of nodes) {
if (typeof node.children === "string")
text += node.children
}
return text || undefined
})
const md = computed(() => (slotContent.value ?? props.content ?? "") as string)
</script>
<template>
<CollapsibleContent
data-slot="ai-reasoning-content"
:class="cn(
'overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
props.class,
)"
>
<div class="mt-2.5 ml-[7px] border-l-2 border-border pl-[23px] text-[13.5px] leading-[1.6] text-muted-foreground">
<Markdown
:content="md"
:link-options="{ favicon: false }"
class="text-[13.5px]! leading-[1.6]! text-muted-foreground! [&>*:first-child]:mt-0! [&>*:last-child]:mb-0! [&>*+*]:mt-2! [&_[data-stream-markdown=p]]:my-0!"
/>
</div>
</CollapsibleContent>
</template>
registry/components/ai/reasoning/ReasoningTrigger.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { SparklesIcon, ChevronDownIcon } from "@lucide/vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
import { Shimmer } from "@/registry/edmi/components/ai/shimmer"
import { useReasoningContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
/** Custom label; return a string. The shimmer is used while streaming either way. */
getThinkingMessage?: (isStreaming: boolean, duration?: number) => string
}>()
const { isStreaming, isOpen, duration } = useReasoningContext()
const label = computed(() => {
if (props.getThinkingMessage)
return props.getThinkingMessage(isStreaming.value, duration.value)
if (isStreaming.value || duration.value === 0)
return "Thinking…"
if (duration.value === undefined)
return "Thought for a few seconds"
return `Thought for ${duration.value} seconds`
})
const shimmering = computed(() => isStreaming.value || duration.value === 0)
</script>
<template>
<CollapsibleTrigger
data-slot="ai-reasoning-trigger"
:class="cn(
'flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
props.class,
)"
>
<slot :is-streaming="isStreaming" :is-open="isOpen" :duration="duration">
<SparklesIcon class="size-4" />
<Shimmer v-if="shimmering" as="span" :duration="1">
{{ label }}
</Shimmer>
<span v-else>{{ label }}</span>
<ChevronDownIcon
:class="cn('ml-auto size-4 transition-transform', isOpen ? 'rotate-180' : 'rotate-0')"
/>
</slot>
</CollapsibleTrigger>
</template>
registry/components/ai/reasoning/context.ts
import type { InjectionKey, Ref } from "vue";
import { inject } from "vue";
export interface ReasoningContextValue {
isStreaming: Ref<boolean>;
isOpen: Ref<boolean>;
setIsOpen: (open: boolean) => void;
duration: Ref<number | undefined>;
}
export const ReasoningKey: InjectionKey<ReasoningContextValue> =
Symbol("ReasoningContext");
export function useReasoningContext() {
const ctx = inject(ReasoningKey);
if (!ctx)
throw new Error("Reasoning components must be used within <Reasoning>");
return ctx;
}
registry/components/ai/reasoning/index.ts
export * from "./context";
export { default as Reasoning } from "./Reasoning.vue";
export { default as ReasoningContent } from "./ReasoningContent.vue";
export { default as ReasoningTrigger } from "./ReasoningTrigger.vue";
src/lib/registry/ai/reasoning/index.ts
import Root from "./reasoning.svelte";
import Content from "./reasoning-content.svelte";
import Trigger from "./reasoning-trigger.svelte";
export * from "./use-reasoning.svelte.js";
export {
Content,
Content as ReasoningContent,
Root,
//
Root as Reasoning,
Trigger,
Trigger as ReasoningTrigger,
};
src/lib/registry/ai/reasoning/reasoning-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { CollapsibleContent } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
import { Streamdown } from "svelte-streamdown";
let {
class: className,
content,
...restProps
}: Omit<ComponentProps<typeof CollapsibleContent>, "children"> & {
/** Markdown source. */
content: string;
} = $props();
const theme = { paragraph: { base: "my-0" } };
</script>
<CollapsibleContent
data-slot="ai-reasoning-content"
class={cn(
"overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className
)}
{...restProps}
>
<div
class="mt-2.5 ml-[7px] border-l-2 border-border pl-[23px] text-[13.5px] leading-[1.6] text-muted-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
>
<Streamdown {content} baseTheme="shadcn" {theme} />
</div>
</CollapsibleContent>
src/lib/registry/ai/reasoning/reasoning-trigger.svelte
<script lang="ts">
import BrainIcon from "phosphor-svelte/lib/Brain";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn } from "$lib/utils.js";
import { CollapsibleTrigger } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
import { Shimmer } from "../shimmer/index.js";
import { useReasoning } from "./use-reasoning.svelte.js";
let {
class: className,
children,
getThinkingMessage,
...restProps
}: ComponentProps<typeof CollapsibleTrigger> & {
/** Custom label; return a string. The shimmer is used while streaming either way. */
getThinkingMessage?: (isStreaming: boolean, duration?: number) => string;
} = $props();
const reasoning = useReasoning();
const label = $derived.by(() => {
if (getThinkingMessage) return getThinkingMessage(reasoning.isStreaming, reasoning.duration);
if (reasoning.isStreaming || reasoning.duration === 0) return "Thinking…";
if (reasoning.duration === undefined) return "Thought for a few seconds";
return `Thought for ${reasoning.duration} seconds`;
});
const shimmering = $derived(reasoning.isStreaming || reasoning.duration === 0);
</script>
<CollapsibleTrigger
data-slot="ai-reasoning-trigger"
class={cn(
"flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<BrainIcon
class="size-4"
/>
{#if shimmering}
<Shimmer as="span" duration={1}>{label}</Shimmer>
{:else}
<span>{label}</span>
{/if}
<CaretDownIcon
class={cn("ml-auto size-4 transition-transform", reasoning.isOpen ? "rotate-180" : "rotate-0")}
/>
{/if}
</CollapsibleTrigger>
src/lib/registry/ai/reasoning/reasoning.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Collapsible } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
import { setReasoningContext } from "./use-reasoning.svelte.js";
let {
class: className,
isStreaming = false,
open = $bindable(),
defaultOpen,
duration = $bindable(),
children,
...restProps
}: Omit<ComponentProps<typeof Collapsible>, "open"> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
/** Seconds. Measured automatically when `isStreaming` flips to false. */
duration?: number;
} = $props();
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
// svelte-ignore state_referenced_locally
if (open === undefined) open = defaultOpen ?? isStreaming;
// svelte-ignore state_referenced_locally
let hasEverStreamed = isStreaming;
let hasAutoClosed = $state(false);
let startTime: number | null = null;
// Track when streaming starts and compute the duration when it ends; auto-open on start unless
// `defaultOpen` was explicitly false.
$effect(() => {
if (isStreaming) {
hasEverStreamed = true;
if (!open && defaultOpen !== false) open = true;
if (startTime === null) startTime = Date.now();
} else if (startTime !== null) {
duration = Math.ceil((Date.now() - startTime) / MS_IN_S);
startTime = null;
}
});
// Auto-close once when streaming ends (only if it ever streamed).
$effect(() => {
if (hasEverStreamed && !isStreaming && open && !hasAutoClosed) {
const timer = setTimeout(() => {
open = false;
hasAutoClosed = true;
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
});
setReasoningContext({
get isStreaming() {
return isStreaming;
},
get isOpen() {
return !!open;
},
get duration() {
return duration;
},
setIsOpen: (value: boolean) => {
open = value;
},
});
</script>
<Collapsible bind:open data-slot="ai-reasoning" class={cn("not-prose", className)} {...restProps}>
{@render children?.()}
</Collapsible>
src/lib/registry/ai/reasoning/use-reasoning.svelte.ts
import { getContext, setContext } from "svelte";
/** Getter object so consumers stay reactive. */
export interface ReasoningContextValue {
readonly isStreaming: boolean;
readonly isOpen: boolean;
readonly duration: number | undefined;
setIsOpen: (open: boolean) => void;
}
const REASONING_KEY = Symbol("ai-reasoning");
export function setReasoningContext(value: ReasoningContextValue) {
return setContext(REASONING_KEY, value);
}
export function useReasoning(): ReasoningContextValue {
const ctx = getContext<ReasoningContextValue | undefined>(REASONING_KEY);
if (!ctx)
throw new Error("Reasoning components must be used within <Reasoning>");
return ctx;
}
Part Prop Type Default
Reasoning isStreaming boolean false
Reasoning open, defaultOpen, onOpenChange controlled / uncontrolled open state defaultOpen = isStreaming
Reasoning duration number (seconds; measured automatically while isStreaming flips)
ReasoningTrigger getThinkingMessage (isStreaming, duration) => ReactNode shimmer Thinking…, then Thought for N seconds
ReasoningContent children string (rendered as markdown)
  • Opens on its own while isStreaming, closes one second after streaming ends and keeps the measured duration as the label.
  • The content sits behind a 2px --border rule; the label is muted until hovered.
  • The shimmer sweep is a --foreground highlight over --muted-foreground, so it reads in light and dark.