shadimage
nexus-ui
uichain-of-thoughtsource: ok · hit

Chain of Thought

Structured multi-step thought timeline with step status, optional expandable output, and auto-close when steps finish

npx shadcn add "https://nexus-ui.dev/r/chain-of-thought.json"
free

Where it goes

statsonboarding

Interaction

animated-counter

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-icons

Registry dependencies

collapsible

Files2 · source cached 10/8/2026, 9:43:45 PM

components/nexus-ui/chain-of-thought.tsx
"use client";

import * as React from "react";
import { Alert02Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

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 ChainOfThoughtRootContextValue = {
  registerStep: (id: string, status: ChainOfThoughtStepStatus) => void;
  allStepsComplete: boolean;
  hasAnyError: boolean;
};

const ChainOfThoughtRootContext =
  React.createContext<ChainOfThoughtRootContextValue | null>(null);

function useChainOfThoughtRootContext(component: string) {
  const ctx = React.useContext(ChainOfThoughtRootContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <ChainOfThought>`);
  }
  return ctx;
}

type ChainOfThoughtStepContextValue = {
  status: ChainOfThoughtStepStatus;
  hasContent: boolean;
};

const ChainOfThoughtStepContext =
  React.createContext<ChainOfThoughtStepContextValue | null>(null);

function useChainOfThoughtStepContext(component: string) {
  const ctx = React.useContext(ChainOfThoughtStepContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <ChainOfThoughtStep>`);
  }
  return ctx;
}

type ChainOfThoughtStepStatus = "pending" | "active" | "completed" | "error";

type ChainOfThoughtProps = Omit<
  React.ComponentProps<typeof Collapsible>,
  "open" | "defaultOpen" | "onOpenChange"
> & {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  autoCloseOnAllComplete?: boolean;
};

function ChainOfThought({
  className,
  open: openProp,
  defaultOpen = true,
  onOpenChange,
  autoCloseOnAllComplete = true,
  children,
  ...props
}: ChainOfThoughtProps) {
  const isControlled = openProp !== undefined;
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
  const [stepStatuses, setStepStatuses] = React.useState<
    Record<string, ChainOfThoughtStepStatus>
  >({});

  const { allStepsComplete, hasAnyError } = React.useMemo(() => {
    const statuses = Object.values(stepStatuses);
    return {
      allStepsComplete:
        statuses.length > 0 &&
        statuses.every((status) => status === "completed"),
      hasAnyError: statuses.some((status) => status === "error"),
    };
  }, [stepStatuses]);

  const open = isControlled ? openProp : internalOpen;

  const registerStep = React.useCallback(
    (id: string, status: ChainOfThoughtStepStatus) => {
      setStepStatuses((prev) => {
        if (prev[id] === status) return prev;
        return { ...prev, [id]: status };
      });
    },
    [],
  );

  const contextValue = React.useMemo(
    () => ({ registerStep, allStepsComplete, hasAnyError }),
    [allStepsComplete, hasAnyError, registerStep],
  );

  const handleOpenChange = React.useCallback(
    (nextOpen: boolean) => {
      if (!isControlled) {
        setInternalOpen(nextOpen);
      }
      onOpenChange?.(nextOpen);
    },
    [isControlled, onOpenChange],
  );

  useOnChange(allStepsComplete, (current, previous) => {
    if (!autoCloseOnAllComplete || isControlled) return;
    if (!previous && current) {
      setInternalOpen(false);
      onOpenChange?.(false);
    }
  });

  return (
    <ChainOfThoughtRootContext.Provider value={contextValue}>
      <Collapsible
        data-slot="chain-of-thought"
        className={cn("not-prose w-full", className)}
        open={open}
        onOpenChange={handleOpenChange}
        {...props}
      >
        {children}
      </Collapsible>
    </ChainOfThoughtRootContext.Provider>
  );
}

type ChainOfThoughtTriggerProps = React.ComponentProps<
  typeof CollapsibleTrigger
> & {
  label?: React.ReactNode;
  icon?: React.ReactNode;
};

function ChainOfThoughtTrigger({
  className,
  icon,
  label,
  children,
  ...props
}: ChainOfThoughtTriggerProps) {
  const { allStepsComplete, hasAnyError } = useChainOfThoughtRootContext(
    "ChainOfThoughtTrigger",
  );
  const isActive = !allStepsComplete && !hasAnyError;

  return (
    <CollapsibleTrigger
      data-slot="chain-of-thought-trigger"
      data-active={String(isActive)}
      className={cn(
        "group flex w-full cursor-pointer items-center gap-1.25 overflow-hidden text-muted-foreground transition-colors hover:text-foreground",
        className,
      )}
      {...props}
    >
      {icon}
      <div className="flex min-w-0 flex-1 items-start gap-1.25 overflow-hidden">
        <TextShimmer
          className="truncate text-left text-sm leading-4.5 text-ellipsis whitespace-nowrap"
          spread={10}
          invertLight
          disableShimmer={!isActive}
        >
          {children ?? label}
        </TextShimmer>
        <HugeiconsIcon
          icon={ArrowDown01Icon}
          strokeWidth={2}
          className="ml-0.5 size-4 shrink-0 opacity-0 transition-all group-hover:opacity-100 group-data-[state=open]:rotate-180 group-data-[state=open]:opacity-100"
        />
      </div>
    </CollapsibleTrigger>
  );
}

type ChainOfThoughtContentProps = React.ComponentProps<
  typeof CollapsibleContent
>;

function ChainOfThoughtContent({
  className,
  children,
  ...props
}: ChainOfThoughtContentProps) {
  return (
    <CollapsibleContent
      data-slot="chain-of-thought-content"
      className={cn(
        "mt-3 space-y-3",
        "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
        className,
      )}
      {...props}
    >
      {children}
    </CollapsibleContent>
  );
}

function hasIconInStepTitle(children: React.ReactNode): boolean {
  return React.Children.toArray(children).some(
    (child) =>
      React.isValidElement<{ icon?: React.ReactNode }>(child) &&
      child.props.icon != null,
  );
}

type ChainOfThoughtStepProps = Omit<
  React.ComponentProps<typeof Collapsible>,
  "open" | "defaultOpen" | "onOpenChange"
> & {
  status?: ChainOfThoughtStepStatus;
  hasContent?: boolean;
  showConnector?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  autoCloseOnComplete?: boolean;
};

function ChainOfThoughtStep({
  className,
  status = "pending",
  hasContent = false,
  showConnector,
  open: openProp,
  defaultOpen = true,
  onOpenChange,
  autoCloseOnComplete = true,
  children,
  ...props
}: ChainOfThoughtStepProps) {
  const { registerStep } = useChainOfThoughtRootContext("ChainOfThoughtStep");
  const stepId = React.useId();
  const showConnectorResolved = showConnector ?? hasIconInStepTitle(children);
  const isControlled = openProp !== undefined;
  const canAutoManageOpen = !isControlled && hasContent;
  const [internalOpen, setInternalOpen] = React.useState(
    () =>
      defaultOpen ||
      (hasContent && (status === "active" || status === "error")),
  );
  const open = isControlled ? openProp : internalOpen;

  React.useEffect(() => {
    registerStep(stepId, status);
  }, [registerStep, status, stepId]);

  const handleOpenChange = React.useCallback(
    (nextOpen: boolean) => {
      if (!isControlled) {
        setInternalOpen(nextOpen);
      }
      onOpenChange?.(nextOpen);
    },
    [isControlled, onOpenChange],
  );

  useOnChange(status, (current, previous) => {
    if (
      canAutoManageOpen &&
      (current === "active" || current === "error") &&
      previous !== current
    ) {
      setInternalOpen(true);
      onOpenChange?.(true);
      return;
    }

    if (!canAutoManageOpen || !autoCloseOnComplete || previous === undefined) {
      return;
    }

    if (previous !== "completed" && current === "completed") {
      setInternalOpen(false);
      onOpenChange?.(false);
    }
  });

  return (
    <ChainOfThoughtStepContext.Provider value={{ status, hasContent }}>
      <Collapsible
        data-slot="chain-of-thought-step"
        className={cn("relative w-full fade-in-0", className)}
        open={open}
        onOpenChange={handleOpenChange}
        {...props}
      >
        {children}
        {showConnectorResolved ? (
          <div
            className={cn(
              "absolute top-4.75 -bottom-2.75 left-2 -mx-px w-px",
              status === "error" ? "bg-destructive/20" : "bg-border/50",
            )}
          ></div>
        ) : null}
      </Collapsible>
    </ChainOfThoughtStepContext.Provider>
  );
}

type ChainOfThoughtStepTitleSharedProps = {
  label?: React.ReactNode;
  icon?: React.ReactNode;
  children?: React.ReactNode;
  collapsible?: boolean;
};

type ChainOfThoughtStepTitleCollapsibleProps =
  ChainOfThoughtStepTitleSharedProps &
    Omit<React.ComponentProps<typeof CollapsibleTrigger>, "children"> & {
      collapsible: true;
    };

type ChainOfThoughtStepTitleStaticProps = ChainOfThoughtStepTitleSharedProps &
  React.HTMLAttributes<HTMLDivElement> & {
    collapsible?: false;
  };

type ChainOfThoughtStepTitleProps =
  | ChainOfThoughtStepTitleCollapsibleProps
  | ChainOfThoughtStepTitleStaticProps;

function ChainOfThoughtStepTitle({
  className,
  label: labelProp,
  icon,
  collapsible,
  children,
  ...props
}: ChainOfThoughtStepTitleProps) {
  const { hasContent, status } = useChainOfThoughtStepContext(
    "ChainOfThoughtStepTitle",
  );
  const isCollapsible = collapsible ?? hasContent;
  const isActive = status === "active";
  const isError = status === "error";
  const resolvedIcon =
    isError && icon ? (
      <HugeiconsIcon icon={Alert02Icon} strokeWidth={1.75} className="size-4" />
    ) : (
      icon
    );
  const label = children ?? labelProp;

  if (!isCollapsible) {
    const staticProps = props as React.HTMLAttributes<HTMLDivElement>;

    return (
      <div
        data-slot="chain-of-thought-step-title"
        data-active={String(isActive)}
        className={cn(
          "group flex items-center text-sm leading-4.5 text-muted-foreground",
          isError && "text-destructive",
          resolvedIcon ? "gap-2" : "gap-0",
          className,
        )}
        {...staticProps}
      >
        {resolvedIcon ? (
          <div className="relative flex size-4 shrink-0 items-center justify-center">
            {resolvedIcon}
          </div>
        ) : null}
        <TextShimmer
          className="truncate text-left text-sm leading-4.5 text-ellipsis whitespace-nowrap"
          spread={10}
          invertLight
          disableShimmer={!isActive}
        >
          {label}
        </TextShimmer>
      </div>
    );
  }

  return (
    <CollapsibleTrigger
      data-slot="chain-of-thought-step-title"
      data-active={String(isActive)}
      className={cn(
        "group flex w-full cursor-pointer items-center text-sm text-muted-foreground transition-colors hover:text-foreground",
        isError && "text-destructive hover:text-destructive/90",
        resolvedIcon ? "gap-2" : "gap-0",
        className,
      )}
      {...(props as Omit<
        React.ComponentProps<typeof CollapsibleTrigger>,
        "children"
      >)}
    >
      {resolvedIcon ? (
        <div className="relative flex size-4 shrink-0 items-center justify-center">
          {resolvedIcon}
        </div>
      ) : null}
      <div className="flex min-w-0 flex-1 items-start gap-1.25 overflow-hidden">
        <TextShimmer
          className="truncate text-left text-sm leading-4.5 text-ellipsis whitespace-nowrap"
          spread={10}
          invertLight
          disableShimmer={!isActive}
        >
          {label}
        </TextShimmer>
        <HugeiconsIcon
          icon={ArrowDown01Icon}
          strokeWidth={2}
          className="ml-0.5 size-4 shrink-0 opacity-0 transition-all group-hover:opacity-100 group-data-[state=open]:rotate-180 group-data-[state=open]:opacity-100"
        />
      </div>
    </CollapsibleTrigger>
  );
}

type ChainOfThoughtStepContentProps = React.ComponentProps<
  typeof CollapsibleContent
>;

function ChainOfThoughtStepContent({
  className,
  children,
  ...props
}: ChainOfThoughtStepContentProps) {
  return (
    <CollapsibleContent
      data-slot="chain-of-thought-step-content"
      className={cn(
        "mt-2 ml-6",
        "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
        className,
      )}
      {...props}
    >
      {children}
    </CollapsibleContent>
  );
}

type ChainOfThoughtCompleteProps = React.HTMLAttributes<HTMLDivElement> & {
  icon?: React.ReactNode;
  label: React.ReactNode;
};

function ChainOfThoughtComplete({
  className,
  icon,
  label,
  ...props
}: ChainOfThoughtCompleteProps) {
  return (
    <div
      data-slot="chain-of-thought-complete"
      className={cn(
        "mt-0 flex items-center gap-2 text-sm leading-4.5 text-muted-foreground fade-in-0",
        !icon && "gap-0",
        className,
      )}
      {...props}
    >
      {icon}
      <span>{label}</span>
    </div>
  );
}

export type { ChainOfThoughtStepStatus };
export {
  ChainOfThought,
  ChainOfThoughtTrigger,
  ChainOfThoughtContent,
  ChainOfThoughtStep,
  ChainOfThoughtStepTitle,
  ChainOfThoughtStepContent,
  ChainOfThoughtComplete,
};
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 →