Collapsible UI for displaying AI reasoning and thinking messages.
Getting Started
Add reasoning
npx shadcn@latest add https://r.assistant-ui.com/reasoning.jsonMain Component
npm install @assistant-ui/react class-variance-authority tw-shimmer"use client";import { memo, useCallback, useRef, useState } from "react";import { cva, type VariantProps } from "class-variance-authority";import { BrainIcon, ChevronDownIcon } from "lucide-react";import { useScrollLock, useAuiState, type ReasoningMessagePartComponent, type ReasoningGroupComponent,} from "@assistant-ui/react";import { MarkdownText } from "@/components/assistant-ui/markdown-text";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { variants: { variant: { outline: "rounded-lg border px-3 py-2", ghost: "", muted: "bg-muted/50 rounded-lg px-3 py-2", }, }, defaultVariants: { variant: "outline", },});export type ReasoningRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & VariantProps<typeof reasoningVariants> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; };function ReasoningRoot({ className, variant, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ReasoningRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="reasoning-root" data-variant={variant} open={isOpen} onOpenChange={handleOpenChange} className={cn( "group/reasoning-root", reasoningVariants({ variant, className }), )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="reasoning-fade" className={cn( "aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8", "bg-[linear-gradient(to_top,var(--color-background),transparent)]", "group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]", "fade-in-0 animate-in", "group-data-[state=open]/collapsible-content:animate-out", "group-data-[state=open]/collapsible-content:fade-out-0", "group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]", "group-data-[state=open]/collapsible-content:fill-mode-forwards", "duration-(--animation-duration)", "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", className, )} {...props} /> );}function ReasoningTrigger({ active, duration, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { active?: boolean; duration?: number;}) { const durationText = duration ? ` (${duration}s)` : ""; return ( <CollapsibleTrigger data-slot="reasoning-trigger" className={cn( "aui-reasoning-trigger group/trigger text-muted-foreground hover:text-foreground flex max-w-[75%] items-center gap-2 py-1 text-sm transition-colors", className, )} {...props} > <BrainIcon data-slot="reasoning-trigger-icon" className="aui-reasoning-trigger-icon size-4 shrink-0" /> <span data-slot="reasoning-trigger-label" className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none" > <span>Reasoning{durationText}</span> {active ? ( <span aria-hidden data-slot="reasoning-trigger-shimmer" className="aui-reasoning-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > Reasoning{durationText} </span> ) : null} </span> <ChevronDownIcon data-slot="reasoning-trigger-chevron" className={cn( "aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ReasoningContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="reasoning-content" className={cn( "aui-reasoning-content text-muted-foreground relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > {children} <ReasoningFade /> </CollapsibleContent> );}function ReasoningText({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="reasoning-text" className={cn( "aui-reasoning-text relative z-0 max-h-64 space-y-4 overflow-y-auto ps-6 pt-2 pb-2 leading-relaxed", "transform-gpu transition-[transform,opacity]", "group-data-[state=open]/collapsible-content:animate-in", "group-data-[state=closed]/collapsible-content:animate-out", "group-data-[state=open]/collapsible-content:fade-in-0", "group-data-[state=closed]/collapsible-content:fade-out-0", "group-data-[state=open]/collapsible-content:slide-in-from-top-4", "group-data-[state=closed]/collapsible-content:slide-out-to-top-4", "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", "group-data-[state=closed]/collapsible-content:duration-(--animation-duration)", className, )} {...props} /> );}const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;const ReasoningGroupImpl: ReasoningGroupComponent = ({ children, startIndex, endIndex,}) => { const isReasoningStreaming = useAuiState((s) => { if (s.message.status?.type !== "running") return false; const lastIndex = s.message.parts.length - 1; if (lastIndex < 0) return false; const lastType = s.message.parts[lastIndex]?.type; if (lastType !== "reasoning") return false; return lastIndex >= startIndex && lastIndex <= endIndex; }); return ( <ReasoningRoot defaultOpen={isReasoningStreaming}> <ReasoningTrigger active={isReasoningStreaming} /> <ReasoningContent aria-busy={isReasoningStreaming}> <ReasoningText>{children}</ReasoningText> </ReasoningContent> </ReasoningRoot> );};const Reasoning = memo( ReasoningImpl,) as unknown as ReasoningMessagePartComponent & { Root: typeof ReasoningRoot; Trigger: typeof ReasoningTrigger; Content: typeof ReasoningContent; Text: typeof ReasoningText; Fade: typeof ReasoningFade;};Reasoning.displayName = "Reasoning";Reasoning.Root = ReasoningRoot;Reasoning.Trigger = ReasoningTrigger;Reasoning.Content = ReasoningContent;Reasoning.Text = ReasoningText;Reasoning.Fade = ReasoningFade;/** * @deprecated This wrapper targets the legacy `components.ReasoningGroup` * prop on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot` * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly. * See `thread.tsx` for an example. */const ReasoningGroup = memo(ReasoningGroupImpl);ReasoningGroup.displayName = "ReasoningGroup";export { Reasoning, ReasoningGroup, ReasoningRoot, ReasoningTrigger, ReasoningContent, ReasoningText, ReasoningFade, reasoningVariants,};assistant-ui dependencies
npm install @assistant-ui/react-markdown radix-ui remark-gfm"use client";import "@assistant-ui/react-markdown/styles/dot.css";import { type CodeHeaderProps, MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock,} from "@assistant-ui/react-markdown";import remarkGfm from "remark-gfm";import { type FC, memo, useState } from "react";import { CheckIcon, CopyIcon } from "lucide-react";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const MarkdownTextImpl = () => { return ( <MarkdownTextPrimitive remarkPlugins={[remarkGfm]} className="aui-md" components={defaultComponents} /> );};export const MarkdownText = memo(MarkdownTextImpl);const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => { const { isCopied, copyToClipboard } = useCopyToClipboard(); const onCopy = () => { if (!code || isCopied) return; copyToClipboard(code); }; return ( <div className="aui-code-header-root border-border/50 bg-muted/50 mt-2.5 flex items-center justify-between rounded-t-lg border border-b-0 px-3 py-1.5 text-xs"> <span className="aui-code-header-language text-muted-foreground font-medium lowercase"> {language} </span> <TooltipIconButton tooltip="Copy" onClick={onCopy}> {!isCopied && <CopyIcon />} {isCopied && <CheckIcon />} </TooltipIconButton> </div> );};const useCopyToClipboard = ({ copiedDuration = 3000,}: { copiedDuration?: number;} = {}) => { const [isCopied, setIsCopied] = useState<boolean>(false); const copyToClipboard = (value: string) => { if (!value || typeof navigator === "undefined" || !navigator.clipboard) { return; } navigator.clipboard.writeText(value).then( () => { setIsCopied(true); setTimeout(() => setIsCopied(false), copiedDuration); }, () => {}, ); }; return { isCopied, copyToClipboard };};const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, ...props }) => ( <h1 className={cn( "aui-md-h1 mb-2 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h2: ({ className, ...props }) => ( <h2 className={cn( "aui-md-h2 mt-3 mb-1.5 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h3: ({ className, ...props }) => ( <h3 className={cn( "aui-md-h3 mt-2.5 mb-1 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h4: ({ className, ...props }) => ( <h4 className={cn( "aui-md-h4 mt-2 mb-1 scroll-m-20 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h5: ({ className, ...props }) => ( <h5 className={cn( "aui-md-h5 mt-2 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h6: ({ className, ...props }) => ( <h6 className={cn( "aui-md-h6 mt-2 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), p: ({ className, ...props }) => ( <p className={cn( "aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0", className, )} {...props} /> ), a: ({ className, ...props }) => ( <a className={cn( "aui-md-a text-primary hover:text-primary/80 underline underline-offset-2", className, )} {...props} /> ), blockquote: ({ className, ...props }) => ( <blockquote className={cn( "aui-md-blockquote border-muted-foreground/30 text-muted-foreground my-2.5 border-s-2 ps-3 italic", className, )} {...props} /> ), ul: ({ className, ...props }) => ( <ul className={cn( "aui-md-ul marker:text-muted-foreground my-2 ms-4 list-disc [&>li]:mt-1", className, )} {...props} /> ), ol: ({ className, ...props }) => ( <ol className={cn( "aui-md-ol marker:text-muted-foreground my-2 ms-4 list-decimal [&>li]:mt-1", className, )} {...props} /> ), hr: ({ className, ...props }) => ( <hr className={cn("aui-md-hr border-muted-foreground/20 my-2", className)} {...props} /> ), table: ({ className, ...props }) => ( <table className={cn( "aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto", className, )} {...props} /> ), th: ({ className, ...props }) => ( <th className={cn( "aui-md-th bg-muted px-2 py-1 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), td: ({ className, ...props }) => ( <td className={cn( "aui-md-td border-muted-foreground/20 border-s border-b px-2 py-1 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), tr: ({ className, ...props }) => ( <tr className={cn( "aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", className, )} {...props} /> ), li: ({ className, ...props }) => ( <li className={cn("aui-md-li leading-normal", className)} {...props} /> ), sup: ({ className, ...props }) => ( <sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} /> ), pre: ({ className, ...props }) => ( <pre className={cn( "aui-md-pre border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-lg border border-t-0 p-3 text-xs leading-relaxed", className, )} {...props} /> ), code: function Code({ className, ...props }) { const isCodeBlock = useIsMarkdownCodeBlock(); return ( <code className={cn( !isCodeBlock && "aui-md-inline-code border-border/50 bg-muted/50 rounded-md border px-1.5 py-0.5 font-mono text-[0.85em]", className, )} {...props} /> ); }, CodeHeader,});"use client";import { type ComponentPropsWithRef, forwardRef } from "react";import { Slot } from "radix-ui";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { tooltip: string; side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef< HTMLButtonElement, TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { return ( <TooltipProvider delayDuration={0}> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" {...rest} className={cn("aui-button-icon size-6 p-1", className)} ref={ref} > <Slot.Slottable>{children}</Slot.Slottable> <span className="aui-sr-only sr-only">{tooltip}</span> </Button> </TooltipTrigger> <TooltipContent side={side}>{tooltip}</TooltipContent> </Tooltip> </TooltipProvider> );});TooltipIconButton.displayName = "TooltipIconButton";This adds a /components/assistant-ui/reasoning.tsx file to your project.
Use in your application
Previously, reasoning parts were rendered via components.Reasoning and grouped via components.ReasoningGroup on MessagePrimitive.Parts. Both are deprecated; MessagePrimitive.GroupedParts is the supported replacement, and the highlighted lines below show the new pieces.
Render reasoning parts through MessagePrimitive.GroupedParts. Group consecutive reasoning parts with "group-reasoning", then compose ReasoningRoot, ReasoningTrigger, ReasoningContent, and ReasoningText around the grouped children.
While reasoning is streaming, part.status.type === "running". Pass that to defaultOpen so the accordion auto-opens during streaming and lets the user collapse it once the model moves on.
import { MessagePrimitive, groupPartByType } from "@assistant-ui/react";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import {
Reasoning,
ReasoningContent,
ReasoningRoot,
ReasoningText,
ReasoningTrigger,
} from "@/components/assistant-ui/reasoning";
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root className="...">
<div className="...">
<MessagePrimitive.GroupedParts
groupBy={groupPartByType({
reasoning: ["group-reasoning"],
})}
>
{({ part, children }) => {
switch (part.type) {
case "group-reasoning": {
const running = part.status.type === "running";
return (
<ReasoningRoot defaultOpen={running}>
<ReasoningTrigger active={running} />
<ReasoningContent aria-busy={running}>
<ReasoningText>{children}</ReasoningText>
</ReasoningContent>
</ReasoningRoot>
);
}
case "text":
return <MarkdownText />;
case "reasoning":
return <Reasoning {...part} />;
case "tool-call":
return part.toolUI ?? <ToolFallback {...part} />;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
</div>
<AssistantActionBar />
<BranchPicker className="..." />
</MessagePrimitive.Root>
);
};GroupedParts calls your render function for both group nodes and leaf parts. The case "group-reasoning" branch renders the collapsible shell and must render {children}; that is where the individual reasoning parts get placed. The case "reasoning" branch renders each individual reasoning part inside that shell and must not render children. Removing either case breaks rendering.
How It Works
The component consists of two parts:
Reasoning: Renders individual reasoning message part content (with markdown support)- Composable group pieces (
ReasoningRoot,ReasoningTrigger,ReasoningContent,ReasoningText): Wrap grouped reasoning children in a collapsible container
Consecutive reasoning parts are grouped by MessagePrimitive.GroupedParts. Use the composable API below to control the grouped layout.
When using the composable API, ReasoningText is a plain container. Add <MarkdownText /> for markdown rendering.
Variants
Use the variant prop on ReasoningRoot to change the visual style:
<ReasoningRoot variant="outline">...</ReasoningRoot>
<ReasoningRoot variant="ghost">...</ReasoningRoot>
<ReasoningRoot variant="muted">...</ReasoningRoot>| Variant | Description |
|---|---|
outline | Rounded border (default) |
ghost | No additional styling |
muted | Muted background |
Legacy ReasoningGroup
ReasoningGroup is kept for existing code that still uses the deprecated components.ReasoningGroup prop on MessagePrimitive.Parts. New code should use MessagePrimitive.GroupedParts and compose the root/trigger/content pieces directly.
import { ReasoningGroup } from "@/components/assistant-ui/reasoning";
const ReasoningGroupImpl: ReasoningGroupComponent = ({
children,
startIndex,
endIndex,
}) => {
const isReasoningStreaming = useAuiState((s) => {
if (s.message.status?.type !== "running") return false;
const lastIndex = s.message.parts.length - 1;
if (lastIndex < 0) return false;
const lastType = s.message.parts[lastIndex]?.type;
if (lastType !== "reasoning") return false;
return lastIndex >= startIndex && lastIndex <= endIndex;
});
return (
<ReasoningRoot defaultOpen={isReasoningStreaming}>
<ReasoningTrigger active={isReasoningStreaming} />
<ReasoningContent aria-busy={isReasoningStreaming}>
<ReasoningText>{children}</ReasoningText>
</ReasoningContent>
</ReasoningRoot>
);
};API Reference
Composable API
All sub-components are exported for custom layouts:
| Component | Description |
|---|---|
ReasoningRoot | Collapsible container with scroll lock |
ReasoningTrigger | Button with icon, label, and shimmer |
ReasoningContent | Animated collapsible content wrapper |
ReasoningText | Text wrapper with slide/fade animation |
ReasoningFade | Gradient fade overlay at bottom |
import {
ReasoningRoot,
ReasoningTrigger,
ReasoningContent,
ReasoningText,
ReasoningFade,
} from "@/components/assistant-ui/reasoning";
<ReasoningRoot variant="muted">
<ReasoningTrigger active={isStreaming} />
<ReasoningContent>
<ReasoningText>{children}</ReasoningText>
</ReasoningContent>
</ReasoningRoot>Related Components
- ToolGroup - Similar grouping pattern for tool calls
- PartGrouping - Advanced grouping options for message parts