All patterns

Comment Thread Expand

Replies unfold beneath a comment while the indent guide draws down beside them.

socialcalmminimalinteraction · 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.

308 lines · react + motion only
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.

Showing replies to a commentNested discussion threadsExpanding a review responseCollapsed issue conversation

Where it shows up

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

  • Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Comment thread

    A collapsed reply count opens in place with the indent rail beside it.

Related patterns