All patterns

Message Reaction Attach

The chosen glyph flies out of the picker and docks at the corner of the bubble it belongs to.

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

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

/**
 * Vibary · Message Reaction Attach
 *
 * Pick a reaction and it flies out of the picker and docks at the corner
 * of the bubble it belongs to — one continuous object, so the reaction is
 * visibly attached to that message rather than merely appearing near it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Glyphs are inline SVG, never emoji, so they inherit the type color and
 * stay crisp at any size. Surfaces are mixed from the inherited text
 * color, so the thread reads correctly on light and dark pages.
 * Works with zero props; tune via `variant`, `message`, `author`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type MessageReactionAttachProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  message?: string;
  author?: string;
  initials?: string;
  timestamp?: string;
  /** Called with the reaction key, or null when yours is taken back. */
  onReactionChange?: (key: string | null) => void;
  /** Reaction color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Seconds between picker glyphs arriving. */
  stagger: number;
  /** px each picker glyph rises from. */
  rise: number;
  /** Carries the glyph from the picker to the dock. */
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the flight lands, it does not bounce. Every spring here
// is at or above a 0.8 damping ratio (damping / 2√stiffness) — a
// reaction that wobbles onto a bubble reads as a sticker being dropped,
// not as a mark being attached. Variants differ in speed and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and quiet. For a busy channel where reactions are constant.
  subtle: {
    stagger: 0.017,
    rise: 3,
    spring: { type: "spring", stiffness: 650, damping: 47 },
  },
  // Enough travel to follow the glyph across. All-purpose.
  default: {
    stagger: 0.035,
    rise: 7,
    spring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A longer arc and a wider fan, for a one-to-one conversation.
  playful: {
    stagger: 0.053,
    rise: 11,
    spring: { type: "spring", stiffness: 380, damping: 33 },
  },
};

const ACCENT = "#E0577F";

/** 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)`;

type Reaction = { key: string; label: string; paths: string[] };

// Inline SVG, drawn on a 24×24 grid. Emoji would inherit the reader's
// font and platform; these do not.
const REACTIONS: readonly Reaction[] = [
  {
    key: "heart",
    label: "Heart",
    paths: ["M12 20s-7.6-4.7-7.6-9.7A4.4 4.4 0 0 1 12 7.5a4.4 4.4 0 0 1 7.6 2.8C19.6 15.3 12 20 12 20Z"],
  },
  {
    key: "up",
    label: "Thumbs up",
    paths: [
      "M8.4 20.4V10.6l3.8-6.4a1.5 1.5 0 0 1 2.8 1.1l-1 4.5h4.4a1.9 1.9 0 0 1 1.9 2.3l-1.3 6.4a1.9 1.9 0 0 1-1.9 1.5Z",
      "M3.6 10.6h4.8v9.8H3.6Z",
    ],
  },
  {
    key: "star",
    label: "Star",
    paths: ["m12 4.3 2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.6-4.8 2.6.9-5.4-3.9-3.8 5.4-.8Z"],
  },
  {
    key: "smile",
    label: "Smile",
    paths: [
      "M12 3.9a8.1 8.1 0 1 1 0 16.2 8.1 8.1 0 0 1 0-16.2Z",
      "M8.3 13.3a4.4 4.4 0 0 0 7.4 0",
      "M9.4 9.7v.7",
      "M14.6 9.7v.7",
    ],
  },
];

/** Reactions other people already left. */
const BASE_COUNTS: Record<string, number> = { heart: 0, up: 3, star: 0, smile: 0 };

function Glyph({
  reaction,
  size,
  active,
  accent,
}: {
  reaction: Reaction;
  size: number;
  active: boolean;
  accent: string;
}) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden
      style={{ display: "block", overflow: "visible" }}
    >
      {reaction.paths.map((d) => (
        <path
          key={d}
          d={d}
          fill={
            active && d.endsWith("Z")
              ? `color-mix(in srgb, ${accent} 22%, transparent)`
              : "none"
          }
          stroke={active ? accent : "currentColor"}
          strokeWidth={1.7}
          strokeLinecap="round"
          strokeLinejoin="round"
          opacity={active ? 1 : 0.66}
        />
      ))}
    </svg>
  );
}

export default function MessageReactionAttach({
  variant = "default",
  message = "Pushed the revised timeline — the launch review moves to Thursday.",
  author = "Dana Reyes",
  initials = "DR",
  timestamp = "9:41",
  onReactionChange,
  accent = ACCENT,
}: MessageReactionAttachProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const uid = useId();

  const [open, setOpen] = useState(false);
  const [mine, setMine] = useState<string | null>(null);

  const choose = (key: string) => {
    const next = mine === key ? null : key;
    setMine(next);
    setOpen(false);
    onReactionChange?.(next);
  };

  const pills = REACTIONS.map((reaction) => ({
    reaction,
    count: BASE_COUNTS[reaction.key] + (mine === reaction.key ? 1 : 0),
    isMine: mine === reaction.key,
  })).filter((pill) => pill.count > 0);

  // Reduced motion: the glyph is not flown across, it is simply in the
  // dock on the next frame. Which reaction landed, and on which message,
  // is carried by position and color rather than by the trip.
  const flight = reduceMotion ? { duration: 0 } : cfg.spring;

  return (
    <div style={{ width: 320, paddingTop: 52 }}>
      <div style={{ display: "flex", alignItems: "flex-end", gap: 9 }}>
        <span
          aria-hidden
          style={{
            width: 28,
            height: 28,
            flexShrink: 0,
            borderRadius: "50%",
            display: "grid",
            placeItems: "center",
            fontSize: 11,
            fontWeight: 600,
            color: "#fff",
            background: "linear-gradient(140deg, #4C7DF0, #7C5AE8)",
          }}
        >
          {initials}
        </span>

        <div style={{ position: "relative", minWidth: 0 }}>
          {/* The picker floats above the bubble it acts on, which is what
              makes the flight downward read as docking. */}
          <AnimatePresence>
            {open && (
              <motion.div
                key="picker"
                initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
                transition={{ duration: 0.16, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  bottom: "calc(100% + 10px)",
                  left: 0,
                  zIndex: 2,
                  display: "flex",
                  gap: 2,
                  padding: 5,
                  borderRadius: 999,
                  background: "Canvas",
                  color: "CanvasText",
                  border: `1px solid ${tone(12)}`,
                  boxShadow: "0 12px 28px rgba(0,0,0,0.18)",
                }}
              >
                {REACTIONS.map((reaction, index) => (
                  <motion.button
                    key={reaction.key}
                    type="button"
                    onClick={() => choose(reaction.key)}
                    aria-label={reaction.label}
                    initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.rise }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{
                      ...flight,
                      delay: reduceMotion ? 0 : index * cfg.stagger,
                    }}
                    whileHover={reduceMotion ? undefined : { y: -2 }}
                    style={{
                      display: "grid",
                      placeItems: "center",
                      width: 30,
                      height: 30,
                      padding: 0,
                      borderRadius: "50%",
                      border: 0,
                      background: "transparent",
                      color: "inherit",
                      cursor: "pointer",
                    }}
                  >
                    {/* Shared identity between picker and dock: Motion
                        carries this glyph across instead of fading one out
                        and another in. */}
                    <motion.span
                      layoutId={reduceMotion ? undefined : `${uid}-${reaction.key}`}
                      transition={{ layout: flight }}
                      style={{ display: "block" }}
                    >
                      <Glyph
                        reaction={reaction}
                        size={19}
                        active={mine === reaction.key}
                        accent={accent}
                      />
                    </motion.span>
                  </motion.button>
                ))}
              </motion.div>
            )}
          </AnimatePresence>

          <div
            style={{
              maxWidth: 244,
              padding: "10px 13px 11px",
              borderRadius: 16,
              borderBottomLeftRadius: 6,
              background: tone(8),
              fontSize: 13,
              lineHeight: 1.45,
            }}
          >
            {message}
            <div style={{ fontSize: 10.5, opacity: 0.45, marginTop: 5 }}>
              {author} · {timestamp}
            </div>
          </div>

          {/* Docked reactions overlap the bubble's bottom edge: they belong
              to the message, so they sit on it rather than beneath it. */}
          <div
            style={{
              position: "absolute",
              left: 12,
              bottom: -13,
              display: "flex",
              gap: 5,
              zIndex: 1,
            }}
          >
            <AnimatePresence initial={false}>
              {pills.map((pill) => (
                <motion.button
                  key={pill.reaction.key}
                  type="button"
                  layout={reduceMotion ? false : "position"}
                  onClick={() => choose(pill.reaction.key)}
                  aria-pressed={pill.isMine}
                  aria-label={`${pill.reaction.label}, ${pill.count}`}
                  initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: reduceMotion ? 0 : -3 }}
                  transition={{
                    opacity: { duration: 0.18, ease: "easeOut" },
                    y: flight,
                    layout: flight,
                  }}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 4,
                    padding: "3px 8px 3px 6px",
                    borderRadius: 999,
                    border: `1px solid ${pill.isMine ? `color-mix(in srgb, ${accent} 40%, transparent)` : tone(10)}`,
                    background: pill.isMine
                      ? `color-mix(in srgb, ${accent} 14%, transparent)`
                      : "Canvas",
                    color: "CanvasText",
                    fontFamily: "inherit",
                    cursor: "pointer",
                  }}
                >
                  <motion.span
                    layoutId={
                      reduceMotion ? undefined : `${uid}-${pill.reaction.key}`
                    }
                    transition={{ layout: flight }}
                    style={{ display: "block" }}
                  >
                    <Glyph
                      reaction={pill.reaction}
                      size={14}
                      active={pill.isMine}
                      accent={accent}
                    />
                  </motion.span>
                  {/* Fixed slot: the number swaps in place, at one size,
                      so a count going from 3 to 4 never nudges the pill. */}
                  <span
                    style={{
                      position: "relative",
                      width: 8,
                      height: 13,
                      fontSize: 11,
                      fontWeight: 600,
                      fontVariantNumeric: "tabular-nums",
                    }}
                  >
                    <AnimatePresence initial={false}>
                      <motion.span
                        key={pill.count}
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.14, ease: "easeOut" }}
                        style={{
                          position: "absolute",
                          inset: 0,
                          lineHeight: "13px",
                          textAlign: "center",
                          color: pill.isMine ? accent : "inherit",
                          opacity: pill.isMine ? 1 : 0.6,
                        }}
                      >
                        {pill.count}
                      </motion.span>
                    </AnimatePresence>
                  </span>
                </motion.button>
              ))}
            </AnimatePresence>
          </div>
        </div>

        <button
          type="button"
          onClick={() => setOpen((value) => !value)}
          aria-expanded={open}
          aria-label="Add a reaction"
          style={{
            display: "grid",
            placeItems: "center",
            flexShrink: 0,
            width: 26,
            height: 26,
            padding: 0,
            borderRadius: "50%",
            border: `1px solid ${tone(12)}`,
            background: open ? tone(10) : tone(4),
            color: "inherit",
            cursor: "pointer",
          }}
        >
          <motion.span
            aria-hidden
            initial={false}
            animate={{ rotate: open ? 45 : 0 }}
            transition={reduceMotion ? { duration: 0 } : cfg.spring}
            style={{ display: "block", opacity: 0.6 }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none">
              <path
                d="M12 5.5v13M5.5 12h13"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
              />
            </svg>
          </motion.span>
        </button>
      </div>
    </div>
  );
}

About this pattern

A reaction is a mark on one specific message, so the motion has to say which one. The glyph the reader picks keeps its identity across the two places it lives — picker and dock — and is carried between them as a single object, which is a claim of ownership no fade could make: the mark that was in your hand is now on that bubble. It lands over the bubble's bottom edge rather than beneath it, the count swaps inside a fixed-width slot so a 3 becoming a 4 never nudges the pill, and taking your reaction back reverses the same path. Every glyph is inline SVG rather than an emoji character, so reactions inherit the interface's type color instead of the reader's platform font.

React to a chat messageComment reactionsQuick acknowledgement in a threadReaction counts on a post

Where it shows up

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

  • Dana Whitfield
    Priya Raman
    Ops standup
    Marcus Bell
    Design sync
    Nils Bergström
    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

    A tapback attaches to the corner of the bubble it was left on.

Related patterns