shadimage
odysseyui
uicomponents-ai-thought-chainsource: ok · hit

Thought Chain

An animated AI reasoning step-by-step thought chain component with collapsible steps and status indicators.

npx shadcn add "https://www.odysseyui.com/r/components-ai-thought-chain.json"
free

Where it goes

statsonboarding

Interaction

animated-counteraccordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion
iconslucide-react

Registry dependencies

collapsiblebadge@odysseyui/components-texts-text-shimmer

Files1 · source cached 10/9/2026, 8:12:37 AM

registry/components/ai/thought-chain/index.tsx
'use client';

import React, { createContext, useContext, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Check, Loader2, Circle, ChevronDown } from 'lucide-react';
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { ShimmerText } from '@/components/odyssey/components/texts/text-shimmer';

type Status = 'done' | 'active' | 'pending';

type StepContextType = {
  open: boolean;
  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
  status: Status;
};

const StepContext = createContext<StepContextType | null>(null);

const statusStyles: Record<
  Status,
  { label: string; line: string; badge: string }
> = {
  done: {
    label: 'text-gray-500',
    line: 'bg-green-600/30',
    badge: '',
  },
  active: {
    label: 'text-gray-900 dark:text-gray-100',
    line: 'bg-blue-500/25',
    badge: 'bg-blue-50 text-blue-500 dark:bg-blue-900/30',
  },
  pending: {
    label: 'text-gray-400',
    line: 'bg-gray-200 dark:bg-gray-700',
    badge: '',
  },
};

function StatusIcon({ status }: { status: Status }) {
  if (status === 'done') {
    return (
      <span className="flex size-5 items-center justify-center rounded-full bg-green-600">
        <Check className="size-3 text-white" strokeWidth={3} />
      </span>
    );
  }

  if (status === 'active') {
    return (
      <motion.span
        animate={{ rotate: 360 }}
        transition={{ repeat: Infinity, duration: 0.75, ease: 'linear' }}
        className="flex size-5 items-center justify-center"
      >
        <Loader2 className="size-5 text-blue-500" />
      </motion.span>
    );
  }

  return <Circle className="size-5 text-gray-300 dark:text-gray-600" />;
}

export function ThoughtChain({ children }: { children: React.ReactNode }) {
  const steps = React.Children.toArray(children);
  const total = steps.length;

  return (
    <div>
      {steps.map((step, index) =>
        React.isValidElement(step)
          ? React.cloneElement(
              step as React.ReactElement<{ _isLast?: boolean }>,
              {
                _isLast: index === total - 1,
              },
            )
          : step,
      )}
    </div>
  );
}

export function ThoughtChainStep({
  children,
  status = 'pending',
  defaultOpen = true,
  _isLast = false,
}: {
  children: React.ReactNode;
  status?: Status;
  defaultOpen?: boolean;
  _isLast?: boolean;
}) {
  const [open, setOpen] = useState(defaultOpen);
  const styles = statusStyles[status];

  return (
    <StepContext.Provider value={{ open, setOpen, status }}>
      <Collapsible open={open} onOpenChange={setOpen}>
        <div className="flex gap-3.5">
          <div className="flex shrink-0 flex-col items-center">
            <span className="mt-0.5">
              <StatusIcon status={status} />
            </span>

            <span
              className={cn(
                'mt-1.5 min-h-5 w-0.5 flex-1 rounded-sm',
                styles.line,
              )}
            />
          </div>

          <div className="flex-1 pb-2">{children}</div>
        </div>
      </Collapsible>
    </StepContext.Provider>
  );
}

export function ThoughtChainTrigger({
  children,
}: {
  children: React.ReactNode;
}) {
  const { open, status } = useContext(StepContext)!;
  const styles = statusStyles[status];

  return (
    <CollapsibleTrigger className="flex cursor-pointer select-none items-center gap-1.5 outline-none">
      <span
        className={cn(
          'text-[13.5px] font-semibold tracking-tight',
          styles.label,
        )}
      >
        {status === 'active' ? (
          typeof children === 'string' ? (
            <ShimmerText text={children} />
          ) : (
            children
          )
        ) : (
          children
        )}
      </span>

      <motion.span
        animate={{ rotate: open ? 180 : 0 }}
        transition={{ duration: 0.2, ease: 'easeInOut' }}
        className="flex items-center text-gray-400"
      >
        <ChevronDown className="size-3.5" />
      </motion.span>

      {status === 'active' && (
        <Badge
          variant="outline"
          className={cn(styles.badge, 'border-blue-200 dark:border-blue-500')}
        >
          In progress
        </Badge>
      )}
    </CollapsibleTrigger>
  );
}

export function ThoughtChainContent({
  children,
}: {
  children: React.ReactNode;
}) {
  const { open } = useContext(StepContext)!;

  return (
    <CollapsibleContent forceMount>
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            key="content"
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.22, ease: 'easeInOut' }}
            className="overflow-hidden"
          >
            <div className="mt-1 pl-0.5">{children}</div>
          </motion.div>
        )}
      </AnimatePresence>
    </CollapsibleContent>
  );
}

export function ThoughtChainItem({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: -4 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3, ease: 'easeOut' }}
      className="flex items-start gap-2 py-1.25"
    >
      <span className="mt-1.75 size-1 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600" />
      <span className="text-[13px] leading-[1.55] text-gray-500 dark:text-gray-400">
        {children}
      </span>
    </motion.div>
  );
}

More from odysseyui

Browse all components in this library →