Message Send
The composed line lifts out of the input and lands as the newest bubble.
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 { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Message Send
*
* The composed line lifts out of the input and lands as the newest
* bubble. The composer empties at the instant the bubble appears, so
* the eye follows one object instead of watching text vanish in one
* place and reappear in another.
*
* The bubble translates and fades; it never scales, because scaling a
* bubble scales the words inside it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The thread 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`, `draft`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MessageSendLiftProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Text the composer starts with, so the demo is one tap from sending. */
draft?: string;
/** Person on the other side of the conversation. */
who?: string;
/** Fires with the text of each sent message. */
onSend?: (text: string) => void;
};
type Message = {
id: number;
from: "them" | "me";
text: string;
/** Seeded history renders at rest; only new messages animate in. */
entering: boolean;
};
type VariantConfig = {
/** px the new bubble rises through. */
lift: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8: the
// bubble arrives with one settle. A bubble that bounces twice reads as
// uncertainty about whether the message actually went.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a rise. For a support console where sends are constant.
subtle: {
lift: 12,
spring: { type: "spring", stiffness: 520, damping: 42 },
},
// Travel enough to trace from the composer. All-purpose.
default: {
lift: 20,
spring: { type: "spring", stiffness: 420, damping: 34 },
},
// A longer rise, for a personal thread on a phone-sized screen.
playful: {
lift: 28,
spring: { type: "spring", stiffness: 360, damping: 31 },
},
};
const ACCENT = "#5B5BD6";
const SEED: Message[] = [
{ id: 1, from: "them", text: "Ready for the handover call at four?", entering: false },
{ id: 2, from: "me", text: "Yes — notes are in the shared folder.", entering: false },
{ id: 3, from: "them", text: "Perfect. Anything I should read first?", entering: false },
];
/** 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)`;
export default function MessageSendLift({
variant = "default",
draft = "The one-pager at the top, the rest is detail.",
who = "Theo Lang",
onSend,
}: MessageSendLiftProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [messages, setMessages] = useState<Message[]>(SEED);
const [text, setText] = useState(draft);
const [sendCount, setSendCount] = useState(0);
const threadRef = useRef<HTMLDivElement>(null);
// The newest message is the one worth seeing, so the thread stays
// pinned to the bottom after every send.
useEffect(() => {
const thread = threadRef.current;
if (!thread) return;
thread.scrollTo({
top: thread.scrollHeight,
behavior: reduceMotion ? "auto" : "smooth",
});
}, [messages, reduceMotion]);
const send = () => {
const body = text.trim();
if (!body) return;
setMessages((previous) => [
...previous,
{ id: previous.length + 1, from: "me", text: body, entering: true },
]);
setText("");
setSendCount((count) => count + 1);
onSend?.(body);
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
width: 310,
borderRadius: 18,
border: `1px solid ${tone(11)}`,
background: tone(4),
overflow: "hidden",
fontSize: 13.5,
}}
>
<div
style={{
padding: "11px 14px",
borderBottom: `1px solid ${tone(9)}`,
fontSize: 12.5,
fontWeight: 600,
}}
>
{who}
</div>
<div
ref={threadRef}
style={{
display: "flex",
flexDirection: "column",
gap: 8,
height: 190,
padding: "12px 14px",
overflowY: "auto",
}}
>
{messages.map((message) => {
const mine = message.from === "me";
return (
<motion.div
key={message.id}
// Seeded history renders at rest: `initial={false}` keeps
// the thread from replaying itself on mount.
initial={
message.entering && !reduceMotion
? { opacity: 0, y: cfg.lift }
: message.entering
? { opacity: 0 }
: false
}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion ? { duration: 0.16, ease: "easeOut" } : cfg.spring
}
style={{
alignSelf: mine ? "flex-end" : "flex-start",
maxWidth: 218,
padding: "9px 12px",
borderRadius: mine ? "15px 15px 5px 15px" : "15px 15px 15px 5px",
background: mine ? ACCENT : tone(8),
border: mine ? "1px solid transparent" : `1px solid ${tone(9)}`,
color: mine ? "#ffffff" : "inherit",
lineHeight: 1.45,
}}
>
{message.text}
</motion.div>
);
})}
</div>
<form
onSubmit={(event) => {
event.preventDefault();
send();
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderTop: `1px solid ${tone(9)}`,
}}
>
<input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="Message"
aria-label="Message"
style={{
flex: 1,
minWidth: 0,
padding: "9px 12px",
borderRadius: 999,
border: `1px solid ${tone(13)}`,
background: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 13,
outline: "none",
}}
/>
<button
type="submit"
disabled={text.trim().length === 0}
aria-label="Send message"
style={{
display: "grid",
placeItems: "center",
width: 34,
height: 34,
flexShrink: 0,
borderRadius: "50%",
border: 0,
background: ACCENT,
color: "#ffffff",
cursor: text.trim() ? "pointer" : "default",
opacity: text.trim() ? 1 : 0.4,
}}
>
{/* The glyph nudges once per send — the smallest possible
acknowledgement that the tap registered, re-keyed so it
replays on every send rather than only the first. */}
<motion.span
key={sendCount}
initial={{ y: 0 }}
animate={reduceMotion ? { y: 0 } : { y: [0, -4, 0] }}
transition={{ duration: 0.32, ease: "easeOut", times: [0, 0.35, 1] }}
style={{ display: "grid", placeItems: "center" }}
>
<svg width="17" height="17" viewBox="0 0 20 20" fill="none" aria-hidden>
<path
d="M10 16V4.6M10 4.6 5.2 9.4M10 4.6l4.8 4.8"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</button>
</form>
</div>
);
}About this pattern
Sending is the one moment in a messaging app where the user is certain something happened, and the motion's job is to confirm where it went. The composer empties at the instant the bubble appears below the thread and rises into place, so the eye follows one object rather than watching text vanish in one place and reappear in another. The bubble travels and fades; it never scales, because scaling a bubble scales the words inside it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
The composed line clears as the bubble arrives at the end of the thread.