All patterns

Reaction Picker

Hold a message and the reactions fan out along a shallow arc above it.

socialfriendlyenergeticinteraction · finite · advanced · ~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.

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

/**
 * Vibary · Reaction Picker
 *
 * Press and hold a message; the reactions leave from it and settle
 * along a shallow arc above it, left to right. The arc is what makes a
 * horizontal row feel like it came out of the bubble — the middle
 * options ride highest, the ends stay low near the anchor.
 *
 * Glyphs are inline SVG, not emoji: they inherit sizing and color, and
 * they render the same on every platform.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The picker floats above the thread, so it uses the CSS system colors
 * `Canvas`/`CanvasText` and lands opaque in a light app and in a dark one.
 * Works with zero props; tune via `variant`, `message`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ReactionPickerArcProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Message the picker is anchored to. */
  message?: string;
  /** Sender shown above the bubble. */
  sender?: string;
  /** How long the press has to last before the picker opens. */
  holdMs?: number;
  /** Fires with the id of the chosen reaction. */
  onReact?: (id: string) => void;
};

type VariantConfig = {
  /** px the middle option rides above the ends. */
  arc: number;
  /** px each option travels up from the anchor. */
  from: number;
  /** Gap between consecutive options arriving. */
  stagger: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8, so each
// option settles once. Five things arriving in sequence multiply any
// wobble by five — this is the pattern where an under-damped spring
// does the most damage.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost flat, almost simultaneous. For a work chat.
  subtle: {
    arc: 3,
    from: 10,
    stagger: 0.018,
    spring: { type: "spring", stiffness: 520, damping: 40 },
  },
  // A readable curve and a left-to-right sweep. All-purpose.
  default: {
    arc: 7,
    from: 16,
    stagger: 0.032,
    spring: { type: "spring", stiffness: 420, damping: 34 },
  },
  // Higher arc, wider sweep, for a personal messaging app where the
  // reaction is half the fun.
  playful: {
    arc: 11,
    from: 22,
    stagger: 0.045,
    spring: { type: "spring", stiffness: 360, damping: 31 },
  },
};

const AMBER = "#F2A93B";

const HEART =
  "M12 20.4C12 20.4 3.6 15 3.6 9.4 3.6 6.6 5.7 4.6 8.3 4.6 9.9 4.6 11.3 5.4 12 6.7 12.7 5.4 14.1 4.6 15.7 4.6 18.3 4.6 20.4 6.6 20.4 9.4 20.4 15 12 20.4 12 20.4Z";

const THUMB =
  "M4.9 10.4h2.6v9.2H4.9A1.9 1.9 0 0 1 3 17.7v-5.4a1.9 1.9 0 0 1 1.9-1.9Zm4.4.1 3.5-6.3a1.9 1.9 0 0 1 3.5 1.2l-.9 4.1h4a2 2 0 0 1 1.95 2.46l-1.3 5.7A2 2 0 0 1 17.65 19.6H9.3Z";

/** A face: saturated disc, features knocked out in white so they stay
 *  legible whichever way the host page is themed. */
function Face({ mouth, wide }: { mouth: string; wide?: boolean }) {
  return (
    <>
      <circle cx="12" cy="12" r="9" fill="currentColor" />
      <circle cx="9" cy="10" r="1.15" fill="#ffffff" />
      <circle cx="15" cy="10" r="1.15" fill="#ffffff" />
      {wide ? (
        <ellipse cx="12" cy="15" rx="2" ry="2.5" fill="#ffffff" />
      ) : (
        <path
          d={mouth}
          stroke="#ffffff"
          strokeWidth="1.7"
          strokeLinecap="round"
          fill="none"
        />
      )}
    </>
  );
}

const REACTIONS = [
  {
    id: "love",
    label: "Love",
    color: "#E8365D",
    glyph: <path d={HEART} fill="currentColor" />,
  },
  {
    id: "like",
    label: "Like",
    color: "#4A7DF0",
    glyph: <path d={THUMB} fill="currentColor" />,
  },
  {
    id: "laugh",
    label: "Laugh",
    color: AMBER,
    glyph: <Face mouth="M8 13.6c1 1.9 2.4 2.8 4 2.8s3-.9 4-2.8" />,
  },
  {
    id: "wow",
    label: "Wow",
    color: AMBER,
    glyph: <Face mouth="" wide />,
  },
  {
    id: "sad",
    label: "Sad",
    color: AMBER,
    glyph: <Face mouth="M8 16.4c1-1.9 2.4-2.8 4-2.8s3 .9 4 2.8" />,
  },
] as const;

/** 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 ReactionPickerArc({
  variant = "default",
  message = "Moved the review to Thursday so everyone can make it.",
  sender = "Theo Lang",
  holdMs = 320,
  onReact,
}: ReactionPickerArcProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [open, setOpen] = useState(false);
  const [chosen, setChosen] = useState<string | null>(null);
  const holdTimer = useRef<number | null>(null);

  const cancelHold = () => {
    if (holdTimer.current !== null) {
      window.clearTimeout(holdTimer.current);
      holdTimer.current = null;
    }
  };

  const beginHold = () => {
    cancelHold();
    holdTimer.current = window.setTimeout(() => setOpen(true), holdMs);
  };

  useEffect(() => cancelHold, []);

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const pick = (id: string) => {
    setChosen(id);
    setOpen(false);
    onReact?.(id);
  };

  const chosenReaction = REACTIONS.find((reaction) => reaction.id === chosen);
  const lastIndex = REACTIONS.length - 1;

  return (
    <div style={{ position: "relative", width: 300, fontSize: 13.5 }}>
      {/* Click-away catcher: an open picker should close on the next tap
          anywhere, not only on a second press of the message. */}
      {open && (
        <div
          aria-hidden
          onClick={() => setOpen(false)}
          style={{ position: "fixed", inset: 0, cursor: "default" }}
        />
      )}

      <div style={{ fontSize: 12, opacity: 0.5, marginBottom: 6 }}>{sender}</div>

      <div style={{ position: "relative", display: "inline-block" }}>
        <button
          type="button"
          onPointerDown={beginHold}
          onPointerUp={cancelHold}
          onPointerLeave={cancelHold}
          onPointerCancel={cancelHold}
          onContextMenu={(event) => event.preventDefault()}
          onKeyDown={(event) => {
            // The hold has no keyboard equivalent, so the same control
            // opens the picker immediately from the keyboard.
            if (event.key === "Enter" || event.key === " ") {
              event.preventDefault();
              setOpen(true);
            }
          }}
          aria-haspopup="menu"
          aria-expanded={open}
          style={{
            display: "block",
            maxWidth: 260,
            padding: "10px 13px",
            borderRadius: "16px 16px 16px 5px",
            border: `1px solid ${tone(10)}`,
            background: tone(8),
            color: "inherit",
            fontFamily: "inherit",
            fontSize: 13.5,
            lineHeight: 1.45,
            textAlign: "left",
            cursor: "pointer",
            userSelect: "none",
            WebkitUserSelect: "none",
            WebkitTouchCallout: "none",
            touchAction: "manipulation",
          }}
        >
          {message}
        </button>

        <AnimatePresence>
          {open && (
            <motion.div
              key="picker"
              role="menu"
              aria-label="React to this message"
              initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduceMotion ? 0 : 4, transition: { duration: 0.12 } }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{
                position: "absolute",
                left: 6,
                bottom: "calc(100% + 8px)",
                display: "flex",
                alignItems: "flex-end",
                gap: 4,
                // The picker sits over the thread, so it cannot be
                // translucent. `Canvas`/`CanvasText` are the CSS system
                // colors for page background and page text: the bar
                // follows the host app's color scheme and stays legible
                // in both.
                background: "Canvas",
                color: "CanvasText",
                border: `1px solid ${tone(14)}`,
                borderRadius: 999,
                padding: `${8 + cfg.arc}px 8px 8px`,
                boxShadow: "0 10px 28px rgba(0,0,0,0.22)",
              }}
            >
              {REACTIONS.map((reaction, index) => {
                // Shallow parabola: ends low near the anchor, middle
                // highest. One expression, no per-item magic numbers.
                const t = (index / lastIndex) * 2 - 1;
                const lift = reduceMotion ? 0 : cfg.arc * (1 - t * t);
                return (
                  <motion.button
                    key={reaction.id}
                    type="button"
                    role="menuitem"
                    onClick={() => pick(reaction.id)}
                    aria-label={reaction.label}
                    initial={
                      reduceMotion
                        ? { opacity: 0, y: 0, scale: 1 }
                        : { opacity: 0, y: cfg.from, scale: 0.6 }
                    }
                    animate={{ opacity: 1, y: -lift, scale: 1 }}
                    exit={{ opacity: 0, y: 6, scale: 0.7, transition: { duration: 0.1 } }}
                    transition={
                      reduceMotion
                        ? { duration: 0.14, ease: "easeOut" }
                        : { ...cfg.spring, delay: index * cfg.stagger }
                    }
                    whileHover={reduceMotion ? undefined : { y: -lift - 3 }}
                    style={{
                      display: "grid",
                      placeItems: "center",
                      width: 34,
                      height: 34,
                      padding: 0,
                      border: 0,
                      borderRadius: "50%",
                      background: "none",
                      color: reaction.color,
                      cursor: "pointer",
                    }}
                  >
                    <svg width="26" height="26" viewBox="0 0 24 24" aria-hidden>
                      {reaction.glyph}
                    </svg>
                  </motion.button>
                );
              })}
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      {/* What the gesture was for. The chip is text plus a glyph, so it
          fades and slides — it never scales. */}
      <AnimatePresence initial={false}>
        {chosenReaction && (
          <motion.div
            key={chosenReaction.id}
            initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            style={{
              position: "relative",
              display: "inline-flex",
              alignItems: "center",
              gap: 5,
              marginTop: 6,
              padding: "3px 9px 3px 6px",
              borderRadius: 999,
              border: `1px solid ${tone(12)}`,
              background: tone(6),
              fontSize: 11.5,
            }}
          >
            <span style={{ display: "grid", placeItems: "center", color: chosenReaction.color }}>
              <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden>
                {chosenReaction.glyph}
              </svg>
            </span>
            <span style={{ opacity: 0.7 }}>1</span>
            <span
              style={{
                position: "absolute",
                width: 1,
                height: 1,
                overflow: "hidden",
                clipPath: "inset(50%)",
                whiteSpace: "nowrap",
              }}
            >
              {chosenReaction.label} added
            </span>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

A long press has no visible affordance, so the reveal has to do the explaining: the options leave from the message and settle along a shallow arc, left to right, close enough together that the whole row reads as one gesture rather than five separate arrivals. The arc is what makes a horizontal row feel like it came out of the bubble — the middle options ride highest, the ends stay low near the anchor. Glyphs are inline SVG rather than emoji, so they inherit the picker's sizing and never depend on a platform font.

Reacting to a messageLong-press context actionsQuick sentiment on a postEmoji-free reaction bar

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

    Holding a bubble raises a small row of reactions anchored to it.

Related patterns