nexus-ui
uireasoningsource: ok · hit
Reasoning
Collapsible model reasoning trace with streaming-aware labels and markdown body
npx shadcn add "https://nexus-ui.dev/r/reasoning.json"free
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@hugeicons/react@hugeicons/core-free-iconsstreamdown
Registry dependencies
collapsible
Files2 · source cached 10/8/2026, 8:17:17 PM
components/nexus-ui/reasoning.tsx
"use client";
import * as React from "react";
import { AiBrain01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { TextShimmer } from "@/components/nexus-ui/text-shimmer";
import { useOnChange } from "@/lib/use-on-change";
import { cn } from "@/lib/utils";
type ReasoningContextValue = {
isStreaming: boolean;
label: string;
};
const ReasoningContext = React.createContext<ReasoningContextValue | null>(
null,
);
function useReasoningContext(component: string): ReasoningContextValue {
const ctx = React.useContext(ReasoningContext);
if (!ctx) {
throw new Error(`${component} must be used within <Reasoning>`);
}
return ctx;
}
type ReasoningProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "defaultOpen" | "onOpenChange"
> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
function Reasoning({
className,
isStreaming = false,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
...props
}: ReasoningProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = React.useState(
defaultOpen || isStreaming,
);
const open = isControlled ? openProp : internalOpen;
const [durationLabel, setDurationLabel] = React.useState<string | null>(null);
const [hasStreamed, setHasStreamed] = React.useState(isStreaming);
const startedAtRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (isStreaming) {
startedAtRef.current = Date.now();
onOpenChange?.(true);
}
}, [isStreaming, onOpenChange]);
useOnChange(isStreaming, (current, previous) => {
if (!previous && current) {
setHasStreamed(true);
startedAtRef.current = Date.now();
setDurationLabel(null);
if (!isControlled) {
setInternalOpen(true);
}
onOpenChange?.(true);
}
if (previous && !current) {
const startedAt = startedAtRef.current;
const elapsedSeconds =
startedAt != null
? Math.max(1, Math.round((Date.now() - startedAt) / 1000))
: null;
setDurationLabel(
elapsedSeconds != null ? String(elapsedSeconds) : "a few",
);
startedAtRef.current = null;
if (!isControlled) {
setInternalOpen(false);
}
onOpenChange?.(false);
}
});
const label = React.useMemo(() => {
if (!hasStreamed || isStreaming) return "Thinking...";
if (durationLabel != null) {
const unit = durationLabel === "1" ? "second" : "seconds";
return `Thought for ${durationLabel} ${unit}`;
}
return "Thought for a few seconds";
}, [durationLabel, hasStreamed, isStreaming]);
const contextValue = React.useMemo(
() => ({ isStreaming, label }),
[isStreaming, label],
);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
const resolvedOpen = isStreaming ? true : nextOpen;
if (!isControlled) {
setInternalOpen(resolvedOpen);
}
onOpenChange?.(resolvedOpen);
},
[isControlled, isStreaming, onOpenChange],
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
data-slot="reasoning"
className={cn("not-prose w-full", className)}
data-streaming={isStreaming ? "true" : "false"}
open={open}
onOpenChange={handleOpenChange}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
type ReasoningTriggerProps = React.ComponentProps<typeof CollapsibleTrigger>;
function ReasoningTrigger({
className,
children,
...props
}: ReasoningTriggerProps) {
const { isStreaming, label } = useReasoningContext("ReasoningTrigger");
return (
<CollapsibleTrigger
data-slot="reasoning-trigger"
data-streaming={isStreaming ? "true" : "false"}
className={cn(
"group flex cursor-pointer items-center gap-1.25 text-muted-foreground transition-colors hover:text-foreground",
className,
)}
{...props}
>
<HugeiconsIcon
icon={AiBrain01Icon}
strokeWidth={1.75}
className="size-4"
/>
<TextShimmer
className="text-sm leading-6"
spread={10}
invertLight
disableShimmer={!isStreaming}
>
{children ?? label}
</TextShimmer>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2.0}
className="ml-0.5 size-4 opacity-0 transition-all group-hover:opacity-100 group-data-[state=open]:rotate-180 group-data-[state=open]:group-data-[streaming=false]:opacity-100"
/>
</CollapsibleTrigger>
);
}
type ReasoningContentProps = Omit<
React.ComponentProps<typeof CollapsibleContent>,
"children"
> & {
children: string;
};
function ReasoningContent({
className,
children,
...props
}: ReasoningContentProps) {
return (
<CollapsibleContent
data-slot="reasoning-content"
className={cn(
"mt-2 ml-2 overflow-hidden border-l pl-3 data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className,
)}
{...props}
>
<Streamdown
className={cn(
"prose max-w-none text-sm leading-6 font-normal text-muted-foreground",
// body text
"[&_p]:mb-2.5",
// strong
"prose-strong:font-medium prose-strong:text-foreground",
// lists
"**:data-[streamdown=list-item]:py-0.25 **:data-[streamdown=list-item]:pl-4 **:data-[streamdown=list-item]:marker:text-muted-foreground/50 prose-ol:my-0 prose-ol:pl-3 prose-ul:my-0 prose-li:my-[-0.5px]",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
)}
>
{children}
</Streamdown>
</CollapsibleContent>
);
}
export { Reasoning, ReasoningTrigger, ReasoningContent };
lib/use-on-change.ts
"use client";
import * as React from "react";
/**
* Runs `onChange` when `value` changes (compared against previous render).
*/
export function useOnChange<T>(
value: T,
onChange: (current: T, previous: T) => void,
isUpdated: (previous: T, current: T) => boolean = Object.is,
) {
const previousRef = React.useRef(value);
React.useEffect(() => {
const previous = previousRef.current;
if (!isUpdated(previous, value)) {
onChange(value, previous);
}
previousRef.current = value;
}, [value, onChange, isUpdated]);
}