All patterns

Quote Post Nest

Quoting a post opens a slot in the composer and settles the post into it as an embedded card.

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

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

/**
 * Vibary · Quote Post Nest
 *
 * Quoting a post files it inside the composer: a slot opens to exactly
 * the height of the embedded card, the card's content settles into it,
 * and the source post is marked as the thing being carried.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the composer reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `post`, `onQuoteChange`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type QuotePostNestProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Start with the post already nested. */
  defaultQuoted?: boolean;
  /** Called when the quote is attached or removed. */
  onQuoteChange?: (quoted: boolean) => void;
  author?: string;
  handle?: string;
  body?: string;
  timestamp?: string;
  /** Accent used for the quoted marker. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Seconds for the slot to open to the card's height. */
  open: number;
  /** Seconds for the card's content to settle in. */
  settle: number;
  /** px the embedded content rises as it arrives. */
  rise: number;
};

// Height is one of the few properties worth animating outright — the
// composer genuinely changes size — so it runs as a short eased tween.
// A spring on height would overshoot, and a container that springs past
// its own content is a container that flashes clipped text. No springs
// here at all; variants differ only in how deliberately the slot opens.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a beat. For a reply box used constantly.
  subtle: { open: 0.2, settle: 0.16, rise: 4 },
  // Enough time to see the card arrive. All-purpose.
  default: { open: 0.28, settle: 0.2, rise: 7 },
  // A slower unfold, for a considered composer on a wide screen.
  playful: { open: 0.36, settle: 0.24, rise: 10 },
};

const ACCENT = "#7C5AE8";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function QuotePostNest({
  variant = "default",
  defaultQuoted = false,
  onQuoteChange,
  author = "Adaeze Okoro",
  handle = "@adaeze",
  body = "Every dashboard we shipped this year replaced a meeting. That was the only brief.",
  timestamp = "2h",
  accent = ACCENT,
}: QuotePostNestProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [quoted, setQuoted] = useState(defaultQuoted);

  const setQuote = (next: boolean) => {
    setQuoted(next);
    onQuoteChange?.(next);
  };

  // Reduced motion: the slot arrives at its new height instead of being
  // animated through it. The card still appears, and still fades.
  const heightTween = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.open, ease: [0.32, 0.72, 0, 1] as const };

  return (
    <div style={{ width: 330, display: "grid", gap: 10 }}>
      <article
        style={{
          position: "relative",
          padding: "13px 14px 10px",
          borderRadius: 16,
          border: `1px solid ${tone(11)}`,
          background: tone(4),
        }}
      >
        {/* The source is marked while it is being carried, so it is clear
            which post ended up in the composer. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: quoted ? 1 : 0, scaleY: quoted ? 1 : 0.4 }}
          transition={{ duration: reduceMotion ? 0 : 0.24, ease: "easeOut" }}
          style={{
            position: "absolute",
            left: 0,
            top: 12,
            bottom: 12,
            width: 3,
            borderRadius: 3,
            background: accent,
            transformOrigin: "center",
          }}
        />
        <PostBody
          author={author}
          handle={handle}
          body={body}
          timestamp={timestamp}
          compact={false}
        />
        <div
          style={{
            display: "flex",
            gap: 8,
            marginTop: 10,
            paddingTop: 9,
            borderTop: `1px solid ${tone(8)}`,
          }}
        >
          <button
            type="button"
            onClick={() => setQuote(!quoted)}
            aria-pressed={quoted}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 6,
              padding: "5px 10px",
              borderRadius: 9,
              border: `1px solid ${quoted ? "transparent" : tone(12)}`,
              background: quoted
                ? `color-mix(in srgb, ${accent} 14%, transparent)`
                : "transparent",
              color: quoted ? accent : "inherit",
              fontFamily: "inherit",
              fontSize: 12,
              fontWeight: 600,
              cursor: "pointer",
            }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden>
              <path
                d="M6.5 17.5h4l2-4.5V6.5h-6v6.5h3ZM15 17.5h4l2-4.5V6.5h-6v6.5h3Z"
                stroke="currentColor"
                strokeWidth="1.7"
                strokeLinejoin="round"
              />
            </svg>
            {quoted ? "Quoted" : "Quote"}
          </button>
        </div>
      </article>

      <div
        style={{
          padding: "12px 14px 11px",
          borderRadius: 16,
          border: `1px solid ${quoted ? `color-mix(in srgb, ${accent} 32%, transparent)` : tone(11)}`,
          background: tone(5),
        }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <span
            aria-hidden
            style={{
              width: 28,
              height: 28,
              flexShrink: 0,
              borderRadius: "50%",
              display: "grid",
              placeItems: "center",
              fontSize: 10.5,
              fontWeight: 600,
              color: "#fff",
              background: "linear-gradient(140deg,#4C7DF0,#7C5AE8)",
            }}
          >
            MV
          </span>
          <input
            aria-label="Add a comment"
            placeholder={quoted ? "Add a comment" : "Say something"}
            style={{
              flex: 1,
              minWidth: 0,
              padding: 0,
              border: 0,
              outline: "none",
              background: "transparent",
              color: "inherit",
              fontFamily: "inherit",
              fontSize: 13,
            }}
          />
        </div>

        {/* The slot opens to the card's own height: the embedded content is
            laid out at final size from the first frame and simply revealed,
            so no line of type is ever scaled by the container growing. */}
        <AnimatePresence initial={false}>
          {quoted && (
            <motion.div
              key="nested"
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{
                height: 0,
                opacity: 0,
                transition: {
                  height: heightTween,
                  opacity: { duration: reduceMotion ? 0 : cfg.settle * 0.6 },
                },
              }}
              transition={{
                height: heightTween,
                opacity: {
                  duration: reduceMotion ? 0.12 : cfg.settle,
                  ease: "easeOut",
                  delay: reduceMotion ? 0 : cfg.settle * 0.4,
                },
              }}
              style={{ overflow: "hidden" }}
            >
              <motion.div
                initial={{ y: reduceMotion ? 0 : cfg.rise }}
                animate={{ y: 0 }}
                exit={{ y: reduceMotion ? 0 : cfg.rise * 0.6 }}
                transition={{
                  duration: reduceMotion ? 0 : cfg.open,
                  ease: [0.32, 0.72, 0, 1],
                }}
                style={{
                  position: "relative",
                  marginTop: 10,
                  padding: "10px 32px 10px 11px",
                  borderRadius: 12,
                  border: `1px solid ${tone(12)}`,
                  background: tone(4),
                }}
              >
                <PostBody
                  author={author}
                  handle={handle}
                  body={body}
                  timestamp={timestamp}
                  compact
                />
                <button
                  type="button"
                  onClick={() => setQuote(false)}
                  aria-label="Remove the quoted post"
                  style={{
                    position: "absolute",
                    top: 8,
                    right: 8,
                    display: "grid",
                    placeItems: "center",
                    width: 20,
                    height: 20,
                    padding: 0,
                    borderRadius: "50%",
                    border: 0,
                    background: tone(10),
                    color: "inherit",
                    cursor: "pointer",
                  }}
                >
                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
                    <path
                      d="M6 6l12 12M18 6 6 18"
                      stroke="currentColor"
                      strokeWidth="2.4"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>

        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            marginTop: 11,
          }}
        >
          <span style={{ fontSize: 11, opacity: 0.42 }}>
            {quoted ? "Quoting one post" : "Anyone can reply"}
          </span>
          <span
            style={{
              padding: "5px 13px",
              borderRadius: 999,
              fontSize: 12,
              fontWeight: 600,
              color: "#fff",
              background: accent,
              opacity: 0.9,
            }}
          >
            Post
          </span>
        </div>
      </div>
    </div>
  );
}

function PostBody({
  author,
  handle,
  body,
  timestamp,
  compact,
}: {
  author: string;
  handle: string;
  body: string;
  timestamp: string;
  compact: boolean;
}) {
  const size = compact ? 22 : 28;
  return (
    <div style={{ display: "flex", gap: 9 }}>
      <span
        aria-hidden
        style={{
          width: size,
          height: size,
          flexShrink: 0,
          borderRadius: "50%",
          display: "grid",
          placeItems: "center",
          fontSize: compact ? 9 : 10.5,
          fontWeight: 600,
          color: "#fff",
          background: "linear-gradient(140deg,#F0A24C,#E0577F)",
        }}
      >
        AO
      </span>
      <span style={{ minWidth: 0 }}>
        <span
          style={{
            display: "flex",
            alignItems: "center",
            gap: 6,
            fontSize: compact ? 11.5 : 12.5,
          }}
        >
          <strong style={{ fontWeight: 650 }}>{author}</strong>
          <span style={{ opacity: 0.42 }}>{handle}</span>
          <span style={{ opacity: 0.42 }}>· {timestamp}</span>
        </span>
        <span
          style={{
            display: "block",
            marginTop: 3,
            fontSize: compact ? 12 : 13,
            lineHeight: 1.45,
            opacity: compact ? 0.68 : 0.85,
          }}
        >
          {body}
        </span>
      </span>
    </div>
  );
}

About this pattern

Quoting is an act of carrying something into your own message, and the motion should make that literal without pretending the post physically flew across the screen. The composer opens a slot to exactly the height of the embedded card on a short eased tween — never a spring, because a container that overshoots its own content flashes clipped type — and the card's content rises into the space as it appears, laid out at final size from the first frame so no line is ever scaled by the box growing around it. Meanwhile the source post grows an accent rule down its edge: of everything on screen, that is the one being carried. Removing the quote runs the same beats backwards, so attaching something you can detach costs nothing.

Quote a post in a composerReply with context attachedForward with a commentEmbed a linked card

Where it shows up

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

  • 10:15
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Social feed

    The quoted post appears as a bordered card inside the composer above the reply field.

Related patterns