All patterns

Summary Condense

The dropped lines fade out from the bottom up, and only then does the card close the height they held.

aielegantpremiuminteraction · finite · intermediate · ~0.6s
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.

280 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion, type Transition } from "motion/react";

/**
 * Vibary · Summary Condense
 *
 * A long passage folding into its summary: the lines being dropped fade
 * out from the bottom up first, and only then does the card close the
 * height they were holding.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The card is 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`, `paragraphs`, `summary`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SummaryCondenseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The full passage, one entry per paragraph. */
  paragraphs?: string[];
  /** What the passage condenses to. */
  summary?: string;
  /** Card heading. */
  title?: string;
  /** Starts condensed instead of full. */
  startCondensed?: boolean;
  /** Fires with the new state each time the card is toggled. */
  onToggle?: (condensed: boolean) => void;
};

type VariantConfig = {
  /** How long one dropped line takes to fade. */
  fadeSeconds: number;
  /** Gap between one line leaving and the next, bottom-up. */
  stagger: number;
  /** The height change itself, once the lines are gone. */
  collapseSeconds: number;
};

// Two beats, never one: the height closing while text is still legible
// reads as the card eating the words. Fades are quick, the collapse is
// eased hard out of the gate, and no text ever scales — a summary that
// pops is a summary nobody trusts.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a straight swap. For a card that condenses often, or several
  // of these stacked in a thread.
  subtle: { fadeSeconds: 0.12, stagger: 0.03, collapseSeconds: 0.28 },
  // The all-purpose setting: the drop is visible, the close is calm.
  default: { fadeSeconds: 0.16, stagger: 0.05, collapseSeconds: 0.38 },
  // A slower, more deliberate fold for a single hero document.
  playful: { fadeSeconds: 0.2, stagger: 0.075, collapseSeconds: 0.48 },
};

const SAMPLE_PARAGRAPHS = [
  "Thanks for the details. I checked the account and the March invoice was charged twice, once on the 3rd and again on the 4th.",
  "The duplicate came from a payment that was retried after a gateway timeout, so both attempts ended up settling.",
  "I have refunded the second charge. Funds usually land back within five business days, depending on the bank.",
];

const SAMPLE_SUMMARY =
  "Duplicate March charge caused by a retried payment. The second charge is refunded and should land within five business days.";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a card, a border and a chip that are correctly
 *  toned in either theme. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function SummaryCondense({
  variant = "default",
  paragraphs = SAMPLE_PARAGRAPHS,
  summary = SAMPLE_SUMMARY,
  title = "Support thread",
  startCondensed = false,
  onToggle,
}: SummaryCondenseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [condensed, setCondensed] = useState(startCondensed);
  // The passage stays mounted at zero opacity for one short beat, so the
  // height is still being held while the words disappear.
  const [dropping, setDropping] = useState(false);

  const dropSeconds =
    cfg.fadeSeconds + Math.max(0, paragraphs.length - 1) * cfg.stagger;

  useEffect(() => {
    if (!dropping) return;
    const id = setTimeout(
      () => {
        setDropping(false);
        setCondensed(true);
      },
      reduceMotion ? 0 : dropSeconds * 1000
    );
    return () => clearTimeout(id);
  }, [dropping, dropSeconds, reduceMotion]);

  const toggle = () => {
    if (condensed) {
      setCondensed(false);
      onToggle?.(false);
      return;
    }
    setDropping(true);
    onToggle?.(true);
  };

  const layoutTransition: Transition = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.collapseSeconds, ease: [0.32, 0.72, 0, 1] };

  return (
    <motion.div
      layout={!reduceMotion}
      transition={layoutTransition}
      style={{
        width: 312,
        display: "flex",
        flexDirection: "column",
        gap: 12,
        padding: 15,
        borderRadius: 15,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
        fontSize: 13,
        overflow: "hidden",
      }}
    >
      <motion.div
        layout={!reduceMotion ? "position" : false}
        transition={layoutTransition}
        style={{ display: "flex", alignItems: "center", gap: 8 }}
      >
        <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
          <path
            d="M3.4 2.6h9.2M3.4 6h9.2M3.4 9.4h6.4M3.4 12.8h4.2"
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinecap="round"
            opacity="0.55"
          />
        </svg>
        <span style={{ fontSize: 12.5, fontWeight: 640 }}>{title}</span>
        {/* Both state names share a grid cell: the label describing the
            card must not resize the row it sits in. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            whiteSpace: "nowrap",
          }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: condensed ? 0 : 0.5 }}
            transition={{ duration: 0.16, ease: "easeOut" }}
            style={{ gridArea: "1 / 1", fontSize: 11 }}
          >
            Full thread
          </motion.span>
          <motion.span
            initial={false}
            animate={{ opacity: condensed ? 1 : 0 }}
            transition={{ duration: 0.16, ease: "easeOut" }}
            style={{
              gridArea: "1 / 1",
              padding: "2px 7px",
              borderRadius: 999,
              background: tone(10),
              fontSize: 10.5,
              fontWeight: 600,
              letterSpacing: "0.03em",
              textTransform: "uppercase",
            }}
          >
            Summary
          </motion.span>
        </span>
      </motion.div>

      <div style={{ display: "grid", gap: 9 }}>
        {condensed ? (
          <motion.p
            key="summary"
            layout={!reduceMotion ? "position" : false}
            initial={{ opacity: 0 }}
            animate={{ opacity: 0.85 }}
            transition={{
              duration: reduceMotion ? 0.14 : 0.26,
              ease: "easeOut",
              delay: reduceMotion ? 0 : 0.12,
            }}
            style={{ margin: 0, lineHeight: 1.55 }}
          >
            {summary}
          </motion.p>
        ) : (
          paragraphs.map((paragraph, index) => (
            // Bottom-up: the last line leaves first, so the block reads as
            // being drawn together rather than erased from the top.
            <motion.p
              key={index}
              layout={!reduceMotion ? "position" : false}
              initial={{ opacity: 0 }}
              animate={{ opacity: dropping ? 0 : 0.85 }}
              transition={{
                duration: reduceMotion ? 0 : cfg.fadeSeconds,
                ease: "easeOut",
                delay: reduceMotion
                  ? 0
                  : (dropping ? paragraphs.length - 1 - index : index) *
                    cfg.stagger,
              }}
              style={{ margin: 0, lineHeight: 1.55 }}
            >
              {paragraph}
            </motion.p>
          ))
        )}
      </div>

      <motion.div
        layout={!reduceMotion ? "position" : false}
        transition={layoutTransition}
        style={{ display: "flex", alignItems: "center", gap: 10 }}
      >
        <motion.span
          initial={false}
          animate={{ opacity: condensed ? 0.45 : 0 }}
          transition={{ duration: 0.2, ease: "easeOut", delay: condensed ? 0.16 : 0 }}
          style={{ fontSize: 11 }}
        >
          Condensed from {paragraphs.length} messages
        </motion.span>
        <button
          type="button"
          onClick={toggle}
          aria-expanded={!condensed}
          style={{
            marginLeft: "auto",
            padding: "6px 11px",
            borderRadius: 9,
            background: tone(8),
            color: "inherit",
            border: `1px solid ${tone(13)}`,
            font: "inherit",
            fontSize: 12,
            fontWeight: 550,
            lineHeight: 1,
            cursor: "pointer",
          }}
        >
          <span style={{ display: "grid", placeItems: "center", whiteSpace: "nowrap" }}>
            <motion.span
              initial={false}
              animate={{ opacity: condensed ? 0 : 1 }}
              transition={{ duration: 0.14, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              Condense
            </motion.span>
            <motion.span
              initial={false}
              animate={{ opacity: condensed ? 1 : 0 }}
              transition={{ duration: 0.14, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              Show full thread
            </motion.span>
          </span>
        </button>
      </motion.div>
    </motion.div>
  );
}

About this pattern

Condensing a long passage is a destructive move — words the reader could see are about to be gone — so the order of the two beats is the whole design. The lines being dropped fade first, from the bottom up, while the card is still holding their height; only once they are invisible does the height ease shut and the summary settle into the space. Collapsing first and fading second reads as the card eating the words. Nothing scales: text that pops on the way out makes a summary look like a trick rather than a service.

Summarize a threadCollapse a long documentTL;DR toggleCondensed email digest

Where it shows up

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

  • 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

    Selected passages fold into a shorter block written in their place.

Related patterns