Reasoning Steps Unfold
A collapsed trace line opens into numbered reasoning steps, each arriving as the panel grows.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A collapsed reasoning summary that expands into the intermediate steps.
Related patterns
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.
- AI Thinking PulseA calm breathing dot with a soft expanding ring — the app is thinking, not stuck.