AI & Chat

Reasoning Disclosure

A thought-for-12s collapsible that shimmers while the model reasons, then blur-crossfades to the elapsed label with muted reasoning text below.

Install

npx shadcn@latest add @paragon/reasoning-disclosure

Also installs: shimmer-text, streaming-text

reasoning-disclosure.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight } from "lucide-react";
import { ShimmerText } from "@/registry/paragon/ui/shimmer-text";
import { cn } from "@/lib/utils";

export interface ReasoningDisclosureProps
  extends React.ComponentProps<"div"> {
  /** Live shimmer on the summary while the model is still reasoning. */
  streaming?: boolean;
  /** Seconds of thought, shown as "Thought for Ns" once streaming ends. */
  duration?: number;
  /** Label while streaming. */
  streamingLabel?: string;
  /** Override the finished label ("Thought for Ns" by default). */
  label?: string;
  defaultExpanded?: boolean;
  /** Controlled expansion. */
  expanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  /** Disables shimmer and crossfade motion. */
  static?: boolean;
}

/**
 * "Thought for 12s" — a collapsible for model reasoning. The summary row
 * blur-crossfades between a live shimmering label while streaming and the
 * final elapsed label; the muted reasoning text expands underneath via
 * grid-template-rows so the open/close is interruptible.
 */
export function ReasoningDisclosure({
  streaming = false,
  duration,
  streamingLabel = "Thinking",
  label,
  defaultExpanded = false,
  expanded,
  onExpandedChange,
  static: isStatic = false,
  className,
  children,
  ...props
}: ReasoningDisclosureProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const panelId = `reasoning-panel-${id}`;
  const reducedMotion = useReducedMotion();
  const [internalOpen, setInternalOpen] = React.useState(defaultExpanded);
  const open = expanded ?? internalOpen;

  const toggle = () => {
    const next = !open;
    setInternalOpen(next);
    onExpandedChange?.(next);
  };

  const doneLabel =
    label ??
    (duration !== undefined ? `Thought for ${duration}s` : "Reasoning");
  const blur = isStatic || reducedMotion ? "blur(0px)" : "blur(4px)";

  return (
    <div data-slot="reasoning-disclosure" className={cn("w-full", className)} {...props}>
      <button
        type="button"
        aria-expanded={open}
        aria-controls={panelId}
        onClick={toggle}
        className="group flex items-center gap-1.5 rounded-md py-1 pr-1.5 text-[13px] text-muted-foreground transition-[color] duration-150 ease-out hover:text-foreground"
      >
        <ChevronRight
          aria-hidden
          className={cn(
            "size-3.5 shrink-0 transition-transform duration-200 ease-out",
            open && "rotate-90",
          )}
        />
        <span className="inline-grid items-baseline">
          {/* Sizing spans keep the row from jumping when the label swaps. */}
          <span aria-hidden className="invisible whitespace-nowrap [grid-area:1/1]">
            {streamingLabel}
          </span>
          <span aria-hidden className="invisible whitespace-nowrap [grid-area:1/1]">
            {doneLabel}
          </span>
          <AnimatePresence initial={false}>
            <motion.span
              key={streaming ? "streaming" : "done"}
              className="whitespace-nowrap [grid-area:1/1]"
              initial={{ opacity: 0, filter: blur }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{
                opacity: 0,
                filter: blur,
                transition: { duration: 0.15 },
              }}
              transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
            >
              {streaming ? (
                <ShimmerText static={isStatic}>{streamingLabel}</ShimmerText>
              ) : (
                <span className="tabular-nums">{doneLabel}</span>
              )}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>
      <div
        className="grid transition-[grid-template-rows] duration-250 ease-out"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div id={panelId} className="min-h-0 overflow-hidden">
          <div className="mt-1 mb-1 ml-[7px] border-l pl-4 text-[13px] leading-relaxed text-muted-foreground">
            {children}
          </div>
        </div>
      </div>
    </div>
  );
}