All patterns

Message Send

The composed line lifts out of the input and lands as the newest bubble.

socialfriendlyenergeticinteraction · finite · intermediate · ~0.4s
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.

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

Sending a chat messagePosting a commentAdding a note to a threadSupport conversation reply

Where it shows up

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

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    The composed line clears as the bubble arrives at the end of the thread.

Related patterns