Quote Post Nest
Quoting a post opens a slot in the composer and settles the post into it as an embedded card.
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 · 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
The quoted post appears as a bordered card inside the composer above the reply field.