All patterns

Reasoning Steps Unfold

A collapsed trace line opens into numbered reasoning steps, each arriving as the panel grows.

aicalmminimalinteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

281 lines · react + motion only
import { useState } from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react";

/**
 * Vibary · Reasoning Steps Unfold
 *
 * A collapsed "Thought for 4 seconds" line that opens into the numbered
 * steps behind an answer, each one arriving as the panel grows.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `summary`, `steps`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ReasoningStepsUnfoldProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The line shown while the trace is collapsed. */
  summary?: string;
  /** Reasoning steps revealed on expand. */
  steps?: string[];
  /** Render already expanded. */
  defaultOpen?: boolean;
  /** Accent for the step numbers and the rail. */
  color?: string;
};

type VariantConfig = {
  /** Height tween. A real size change, so it stays short and eased. */
  openSeconds: number;
  /** Beat between the panel opening and the first step landing. */
  delayChildren: number;
  staggerChildren: number;
  /** px a step travels up as it lands. */
  stepRise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. The
// panel is a wall of text; a bouncing paragraph is unreadable for the
// half second it is still moving, and reading is the entire point here.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.07 — no overshoot, barely any travel. For traces shown on
  // every turn, where the open is a utility rather than a moment.
  subtle: {
    openSeconds: 0.22,
    delayChildren: 0.04,
    staggerChildren: 0.035,
    stepRise: 4,
    spring: { type: "spring", stiffness: 460, damping: 46 },
  },
  // ζ ≈ 0.93 — lands clean. The all-purpose setting.
  default: {
    openSeconds: 0.28,
    delayChildren: 0.06,
    staggerChildren: 0.05,
    stepRise: 7,
    spring: { type: "spring", stiffness: 420, damping: 38 },
  },
  // ζ ≈ 0.82 — one soft settle and more travel, for a trace the product
  // wants people to actually open.
  playful: {
    openSeconds: 0.34,
    delayChildren: 0.08,
    staggerChildren: 0.065,
    stepRise: 11,
    spring: { type: "spring", stiffness: 380, damping: 32 },
  },
};

const SAMPLE_STEPS = [
  "Read the attached invoice and pulled out every line item.",
  "Matched each line against the original order.",
  "Found one seat charged twice on the renewal date.",
  "Checked the refund window — six days still left on it.",
];

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ReasoningStepsUnfold({
  variant = "default",
  summary = "Thought for 4 seconds",
  steps = SAMPLE_STEPS,
  defaultOpen = false,
  color = "#7C7CF0",
}: ReasoningStepsUnfoldProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [open, setOpen] = useState(defaultOpen);

  // Reduced motion: the panel still opens and closes — that is the
  // information — but it does so on a fade, with no height tween and no
  // per-step travel. Nothing slides, everything is still readable.
  const panelVariants: Variants = reduceMotion
    ? {
        hidden: { opacity: 0 },
        shown: { opacity: 1, transition: { duration: 0.18, ease: "easeOut" } },
      }
    : {
        hidden: { height: 0, opacity: 0 },
        shown: {
          height: "auto",
          opacity: 1,
          transition: {
            height: { duration: cfg.openSeconds, ease: [0.22, 1, 0.36, 1] },
            opacity: { duration: 0.16, ease: "easeOut" },
            delayChildren: cfg.delayChildren,
            staggerChildren: cfg.staggerChildren,
          },
        },
      };

  const stepVariants: Variants = reduceMotion
    ? {
        hidden: { opacity: 0 },
        shown: { opacity: 1, transition: { duration: 0.18, ease: "easeOut" } },
      }
    : {
        hidden: { opacity: 0, y: cfg.stepRise },
        shown: {
          opacity: 1,
          y: 0,
          transition: {
            y: cfg.spring,
            // Opacity on its own quick curve; springing a fade looks muddy.
            opacity: { duration: 0.2, ease: "easeOut" },
          },
        },
      };

  return (
    <div
      style={{
        width: 320,
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
        overflow: "hidden",
      }}
    >
      <button
        type="button"
        onClick={() => setOpen((value) => !value)}
        aria-expanded={open}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 9,
          width: "100%",
          padding: "11px 13px",
          background: "transparent",
          border: "none",
          color: "inherit",
          font: "inherit",
          fontSize: 13,
          textAlign: "left",
          cursor: "pointer",
        }}
      >
        <motion.span
          aria-hidden
          animate={{ rotate: open ? 90 : 0 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { type: "spring", stiffness: 500, damping: 40 }
          }
          style={{ display: "inline-flex", opacity: 0.55 }}
        >
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
            <path
              d="M4.5 2.5 8 6l-3.5 3.5"
              stroke="currentColor"
              strokeWidth="1.6"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </motion.span>
        <span style={{ opacity: 0.72, fontWeight: 550 }}>{summary}</span>
        <span style={{ marginLeft: "auto", fontSize: 11.5, opacity: 0.42 }}>
          {steps.length} steps
        </span>
      </button>

      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            key="panel"
            variants={panelVariants}
            initial="hidden"
            animate="shown"
            exit="hidden"
            // The panel is the one thing here whose size genuinely
            // changes, so height is tweened rather than transformed —
            // kept short and hard-eased so the text below settles fast.
            style={{ overflow: "hidden" }}
          >
            <ol
              style={{
                position: "relative",
                display: "grid",
                gap: 11,
                margin: 0,
                padding: "2px 14px 14px 14px",
                listStyle: "none",
              }}
            >
              {/* The rail behind the numbers: drawn with scaleY so the
                  connector appears to extend as the steps arrive. */}
              <motion.span
                aria-hidden
                initial={reduceMotion ? false : { scaleY: 0 }}
                animate={{ scaleY: 1 }}
                transition={{
                  duration: cfg.openSeconds + cfg.staggerChildren * steps.length,
                  ease: "easeOut",
                }}
                style={{
                  position: "absolute",
                  left: 23,
                  top: 12,
                  bottom: 20,
                  width: 1,
                  background: tone(14),
                  transformOrigin: "top center",
                }}
              />
              {steps.map((step, index) => (
                <motion.li
                  key={step}
                  variants={stepVariants}
                  style={{
                    display: "flex",
                    alignItems: "flex-start",
                    gap: 10,
                  }}
                >
                  <span
                    style={{
                      flex: "0 0 auto",
                      width: 19,
                      height: 19,
                      marginTop: 1,
                      borderRadius: "50%",
                      display: "grid",
                      placeItems: "center",
                      fontSize: 10.5,
                      fontWeight: 650,
                      fontVariantNumeric: "tabular-nums",
                      color,
                      background: tone(7),
                      border: `1px solid ${tone(13)}`,
                    }}
                  >
                    {index + 1}
                  </span>
                  <span style={{ fontSize: 12.5, lineHeight: 1.5, opacity: 0.68 }}>
                    {step}
                  </span>
                </motion.li>
              ))}
            </ol>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

The trace behind an answer, kept out of the way until someone wants it. Collapsed, it is a single quiet line; expanded, it is the ordered account of how the answer was reached. The panel height tweens on a hard-eased curve while the steps land on a short stagger, so the eye follows the account downward instead of being handed a wall of prose. The rail beside the numbers extends with them, which is what makes the list read as a sequence rather than a set.

Chain of thought disclosureAgent trace panelExplain the answerAudit log expansion

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A collapsed reasoning summary that expands into the intermediate steps.

Related patterns