shadimage
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]);
}

More from nexus-ui

Browse all components in this library →