Comment Thread Expand
Replies unfold beneath a comment while the indent guide draws down beside them.
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 } from "motion/react";
/**
* Vibary · Comment Thread Expand
*
* Replies unfold beneath a comment. The container grows to its natural
* height, the indent guide draws down beside it, and the replies arrive
* on a stagger just behind the guide — so the eye is led to the new
* content rather than handed a block that appeared from nowhere.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Every surface is mixed from the inherited text color, so the thread
* reads correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `comment`, `replies`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type Reply = {
id: string;
name: string;
initials: string;
/** Disc color behind the initials — stands in for a photo. */
tint: string;
time: string;
body: string;
};
export type CommentThreadExpandProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Uncontrolled starting state. */
defaultOpen?: boolean;
/** The parent comment. Falls back to a sample one. */
comment?: Reply;
/** Replies underneath it. Falls back to three samples. */
replies?: Reply[];
};
type VariantConfig = {
/** How long the container takes to reach its natural height. */
growSeconds: number;
/** Gap between consecutive replies arriving. */
stagger: number;
/** px each reply rises through. */
rise: number;
};
// Height is the one property here allowed to tween, because the motion
// genuinely is a size change. It stays short and eased — a slow height
// tween is the most common way a thread starts to feel sluggish.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and flat, for a dense comment list with many collapsed threads.
subtle: { growSeconds: 0.22, stagger: 0.03, rise: 4 },
// Enough stagger to read as an unfolding. All-purpose.
default: { growSeconds: 0.28, stagger: 0.05, rise: 7 },
// A longer draw and a wider stagger, for a single focused discussion.
playful: { growSeconds: 0.34, stagger: 0.07, rise: 10 },
};
const EASE = [0.32, 0.72, 0, 1] as const;
const SAMPLE_COMMENT: Reply = {
id: "c1",
name: "Maya Kwon",
initials: "MK",
tint: "#7C7CF0",
time: "2h",
body: "Second option is the one — the label finally has room to breathe on a narrow screen.",
};
const SAMPLE_REPLIES: Reply[] = [
{
id: "r1",
name: "Theo Lang",
initials: "TL",
tint: "#5B8DEF",
time: "1h",
body: "Agreed. Can we keep the count visible while the thread is collapsed?",
},
{
id: "r2",
name: "Amara Osei",
initials: "AO",
tint: "#E08A3C",
time: "48m",
body: "Yes — it is the only thing telling you there is anything down here.",
},
{
id: "r3",
name: "Jonas Vik",
initials: "JV",
tint: "#3FA98B",
time: "12m",
body: "Pushed a build with both. Link is in the channel.",
},
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
function Avatar({ initials, tint, size }: { initials: string; tint: string; size: number }) {
return (
<span
aria-hidden
style={{
flexShrink: 0,
display: "grid",
placeItems: "center",
width: size,
height: size,
borderRadius: "50%",
background: tint,
color: "#ffffff",
fontSize: size * 0.38,
fontWeight: 650,
}}
>
{initials}
</span>
);
}
export default function CommentThreadExpand({
variant = "default",
defaultOpen = false,
comment = SAMPLE_COMMENT,
replies = SAMPLE_REPLIES,
}: CommentThreadExpandProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [open, setOpen] = useState(defaultOpen);
// Reduced motion: the replies still appear and the guide is still
// there — the height tween, the draw and the stagger all collapse to
// a plain fade, which is exactly the movement the setting asks us to
// drop.
const grow = reduceMotion ? 0 : cfg.growSeconds;
const rise = reduceMotion ? 0 : cfg.rise;
const stagger = reduceMotion ? 0 : cfg.stagger;
const listVariants = {
hidden: {},
shown: { transition: { staggerChildren: stagger, delayChildren: grow * 0.3 } },
};
const rowVariants = {
hidden: { opacity: 0, y: rise },
shown: {
opacity: 1,
y: 0,
transition: { duration: reduceMotion ? 0.14 : 0.3, ease: EASE },
},
};
return (
<div
style={{
width: 320,
padding: 16,
borderRadius: 16,
border: `1px solid ${tone(11)}`,
background: tone(4),
fontSize: 13.5,
}}
>
<div style={{ display: "flex", gap: 10 }}>
<Avatar initials={comment.initials} tint={comment.tint} size={34} />
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 7 }}>
<span style={{ fontWeight: 600 }}>{comment.name}</span>
<span style={{ fontSize: 11.5, opacity: 0.45 }}>{comment.time}</span>
</div>
<p style={{ margin: "3px 0 0", lineHeight: 1.5 }}>{comment.body}</p>
</div>
</div>
<button
type="button"
onClick={() => setOpen((previous) => !previous)}
aria-expanded={open}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
margin: "10px 0 0 44px",
padding: "5px 10px 5px 8px",
borderRadius: 999,
border: `1px solid ${tone(12)}`,
background: "none",
color: "inherit",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 550,
cursor: "pointer",
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.24, ease: EASE }}
style={{ display: "grid", placeItems: "center" }}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path
d="M4 6.2 8 10.2 12 6.2"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
{open ? "Hide replies" : `Show ${replies.length} replies`}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="replies"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
// Collapsing runs faster than expanding: nobody needs to
// watch content leave.
exit={{
height: 0,
opacity: 0,
transition: {
height: { duration: grow * 0.75, ease: "easeIn" },
opacity: { duration: 0.12 },
},
}}
transition={{
height: { duration: grow, ease: EASE },
opacity: { duration: reduceMotion ? 0.14 : 0.18, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<div style={{ position: "relative", padding: "12px 0 0 44px" }}>
{/* The indent guide draws downward from under the parent
avatar. scaleY on a 1.5px rail is a transform, so it
costs nothing and it points at where the replies land. */}
<motion.span
aria-hidden
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
exit={{ scaleY: 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.growSeconds * 1.3,
ease: EASE,
}}
style={{
position: "absolute",
left: 16,
top: 6,
bottom: 6,
width: 1.5,
borderRadius: 1,
background: tone(16),
transformOrigin: "top",
}}
/>
<motion.ul
variants={listVariants}
initial="hidden"
animate="shown"
exit="hidden"
style={{
display: "flex",
flexDirection: "column",
gap: 12,
margin: 0,
padding: 0,
listStyle: "none",
}}
>
{replies.map((reply) => (
<motion.li
key={reply.id}
variants={rowVariants}
style={{ display: "flex", gap: 9 }}
>
<Avatar initials={reply.initials} tint={reply.tint} size={26} />
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 7 }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>
{reply.name}
</span>
<span style={{ fontSize: 11, opacity: 0.45 }}>{reply.time}</span>
</div>
<p style={{ margin: "2px 0 0", fontSize: 12.5, lineHeight: 1.5 }}>
{reply.body}
</p>
</div>
</motion.li>
))}
</motion.ul>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
Collapsed replies are a promise that opening them will not lose your place. The container grows to its natural height on a short eased tween, the indent guide draws downward from the parent avatar, and the replies arrive on a stagger just behind it — so the eye follows the guide to where the new content is instead of being handed a block that appeared from nowhere. Collapsing runs faster than expanding, because nobody needs to watch content leave.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Comment thread
A collapsed reply count opens in place with the indent rail beside it.
Related patterns
- Read More ExpandA truncated post opens to full height while the fade that hid the cut-off line lifts with it.
- Unread DividerA marker draws across the thread where you stopped reading, then fades without moving anything.
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.