All patterns

Swipe to Reply

Dragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.

socialfriendlypremiuminteraction · finite · advanced · ~0.6s
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.

400 lines · react + motion only
import { useRef, useState } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Swipe to Reply
 *
 * Drag a message sideways and the reply mark is uncovered by the gesture
 * itself: it grows, turns and colors as a function of how far the finger
 * has travelled, snaps once at the threshold, and the bubble rubber-bands
 * home whether or not the reply was armed.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the thread reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `message`, `onReply`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SwipeToReplyProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Fired once when a swipe passes the threshold and is released. */
  onReply?: (message: string) => void;
  message?: string;
  sender?: string;
  initials?: string;
  timestamp?: string;
  /** Armed color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** px of travel needed before the gesture arms. */
  threshold: number;
  /** Rubber-band resistance on the drag. */
  elastic: number;
  /** How much the mark snaps when it arms. */
  snap: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: every spring is at or above a 0.8 damping ratio
// (damping / 2√stiffness). A message that boings back past its resting
// place feels like a toy the second time you use it, and this gesture is
// used constantly. Variants differ in how far you pull, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short pull, tight snap back. For a dense desktop thread.
  subtle: {
    threshold: 44,
    elastic: 0.45,
    snap: 1.1,
    spring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // A pull you have to mean. All-purpose.
  default: {
    threshold: 58,
    elastic: 0.55,
    snap: 1.16,
    spring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // Longer travel and a softer landing, for a full-screen mobile thread.
  playful: {
    threshold: 72,
    elastic: 0.65,
    snap: 1.22,
    spring: { type: "spring", stiffness: 400, damping: 34 },
  },
};

const ACCENT = "#4C7DF0";

/** 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 SwipeToReply({
  variant = "default",
  onReply,
  message = "The revised numbers are in the shared doc — take a look before Thursday.",
  sender = "Dana Reyes",
  initials = "DR",
  timestamp = "9:41",
  accent = ACCENT,
}: SwipeToReplyProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const x = useMotionValue(0);
  const snap = useMotionValue(1);
  const [armed, setArmed] = useState(false);
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const wasArmed = useRef(false);

  // One subscription instead of a render per frame: the component only
  // re-renders on the two frames where the gesture crosses the threshold.
  useMotionValueEvent(x, "change", (value) => {
    const next = value >= cfg.threshold;
    if (next === wasArmed.current) return;
    wasArmed.current = next;
    setArmed(next);
    // The snap is the only thing in the gesture that is not a projection
    // of position: it is feedback for crossing a line, so it fires once.
    if (next && !reduceMotion) {
      animate(snap, [1, cfg.snap, 1], { duration: 0.28, ease: "easeOut" });
    }
  });

  // Everything the mark does during the drag is derived from the drag.
  // useTransform clamps at both ends, so over-pulling changes nothing —
  // the gesture is already armed.
  const markOpacity = useTransform(x, [4, cfg.threshold * 0.55], [0, 1]);
  const markScale = useTransform(x, [0, cfg.threshold], [0.55, 1]);
  const markTurn = useTransform(x, [0, cfg.threshold], [-38, 0]);
  const markShift = useTransform(x, [0, cfg.threshold], [-10, 0]);

  const settle = reduceMotion
    ? { duration: 0.15, ease: "easeOut" as const }
    : cfg.spring;

  const fireReply = () => {
    setReplyingTo(message);
    onReply?.(message);
  };

  return (
    <div style={{ width: 320 }}>
      <div
        style={{
          position: "relative",
          padding: "14px 12px",
          borderRadius: 18,
          border: `1px solid ${tone(11)}`,
          background: tone(4),
          overflow: "hidden",
        }}
      >
        <div style={{ position: "relative", display: "flex", alignItems: "center" }}>
          {/* The mark lives behind the message and is uncovered by the
              drag, which is why it cannot be a canned clip: it is only ever
              as visible as the gesture has made it. */}
          <motion.span
            aria-hidden
            style={{
              position: "absolute",
              left: 6,
              display: "grid",
              placeItems: "center",
              width: 30,
              height: 30,
              borderRadius: "50%",
              background: armed
                ? `color-mix(in srgb, ${accent} 18%, transparent)`
                : tone(9),
              color: armed ? accent : "inherit",
              opacity: markOpacity,
              x: markShift,
              scale: markScale,
            }}
          >
            <motion.span style={{ display: "block", scale: snap, rotate: markTurn }}>
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden>
                <path
                  d="M10 6 4.6 11.4 10 16.8"
                  stroke="currentColor"
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
                <path
                  d="M4.6 11.4h8.6a5.6 5.6 0 0 1 5.6 5.6V19"
                  stroke="currentColor"
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </motion.span>
          </motion.span>

          <motion.div
            drag={reduceMotion ? false : "x"}
            dragDirectionLock
            dragConstraints={{ left: 0, right: 0 }}
            dragElastic={{ left: 0, right: cfg.elastic }}
            dragMomentum={false}
            onDragEnd={() => {
              if (x.get() >= cfg.threshold) fireReply();
              animate(x, 0, settle);
            }}
            role="button"
            tabIndex={0}
            aria-label={`Message from ${sender}. Activate to reply.`}
            onKeyDown={(event) => {
              if (event.key !== "Enter" && event.key !== " ") return;
              event.preventDefault();
              fireReply();
            }}
            style={{
              x,
              display: "flex",
              alignItems: "flex-end",
              gap: 9,
              // Without this the browser claims the horizontal gesture and
              // the drag never reaches the component on a touch screen.
              touchAction: "pan-y",
              cursor: reduceMotion ? "default" : "grab",
            }}
          >
            <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,#F0A24C,#E0577F)",
              }}
            >
              {initials}
            </span>
            <span
              style={{
                maxWidth: 236,
                padding: "10px 13px 11px",
                borderRadius: 16,
                borderBottomLeftRadius: 6,
                background: tone(9),
                fontSize: 13,
                lineHeight: 1.45,
              }}
            >
              {message}
              <span
                style={{ display: "block", fontSize: 10.5, opacity: 0.45, marginTop: 5 }}
              >
                {sender} · {timestamp}
              </span>
            </span>
          </motion.div>
        </div>
      </div>

      {/* The composer takes the quote: the gesture's outcome, not another
          animation of it. */}
      <div
        style={{
          marginTop: 10,
          borderRadius: 16,
          border: `1px solid ${tone(11)}`,
          background: tone(5),
          overflow: "hidden",
        }}
      >
        <AnimatePresence initial={false}>
          {replyingTo && (
            <motion.div
              key="quote"
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{
                height: { duration: reduceMotion ? 0 : 0.24, ease: [0.32, 0.72, 0, 1] },
                opacity: { duration: 0.16, ease: "easeOut" },
              }}
              style={{ overflow: "hidden" }}
            >
              <div
                style={{
                  display: "flex",
                  alignItems: "flex-start",
                  gap: 8,
                  margin: "10px 12px 0",
                  padding: "8px 10px",
                  borderRadius: 10,
                  borderLeft: `3px solid ${accent}`,
                  background: tone(6),
                }}
              >
                <span style={{ flex: 1, minWidth: 0 }}>
                  <span
                    style={{
                      display: "block",
                      fontSize: 11,
                      fontWeight: 650,
                      color: accent,
                    }}
                  >
                    Replying to {sender}
                  </span>
                  <span
                    style={{
                      display: "block",
                      fontSize: 11.5,
                      opacity: 0.55,
                      marginTop: 2,
                      overflow: "hidden",
                      textOverflow: "ellipsis",
                      whiteSpace: "nowrap",
                    }}
                  >
                    {replyingTo}
                  </span>
                </span>
                <button
                  type="button"
                  onClick={() => setReplyingTo(null)}
                  aria-label="Cancel the reply"
                  style={{
                    display: "grid",
                    placeItems: "center",
                    width: 18,
                    height: 18,
                    flexShrink: 0,
                    padding: 0,
                    borderRadius: "50%",
                    border: 0,
                    background: tone(10),
                    color: "inherit",
                    cursor: "pointer",
                  }}
                >
                  <svg width="9" height="9" viewBox="0 0 24 24" fill="none" aria-hidden>
                    <path
                      d="M6 6l12 12M18 6 6 18"
                      stroke="currentColor"
                      strokeWidth="2.6"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>

        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 10,
            padding: "11px 13px",
          }}
        >
          <input
            aria-label="Write a message"
            placeholder={replyingTo ? "Write your reply" : "Message"}
            style={{
              flex: 1,
              minWidth: 0,
              padding: 0,
              border: 0,
              outline: "none",
              background: "transparent",
              color: "inherit",
              fontFamily: "inherit",
              fontSize: 13,
            }}
          />
          <span
            aria-hidden
            style={{
              display: "grid",
              placeItems: "center",
              width: 28,
              height: 28,
              borderRadius: "50%",
              background: accent,
              color: "#fff",
            }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none">
              <path
                d="M4.5 12h13M12 5.5 18.5 12 12 18.5"
                stroke="currentColor"
                strokeWidth="2.1"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </span>
        </div>
      </div>
    </div>
  );
}

About this pattern

The gesture is the interface here, so nothing about it is pre-recorded: the mark's opacity, scale, turn and offset are all projections of how far the message has travelled, which means it sits exactly where the finger left it rather than where a timeline says it should be. Crossing the threshold does the one thing a projection cannot — it snaps, once, as feedback for passing a line you cannot see. Releasing rubber-bands the message home with an over-damped spring whether or not the reply was armed, because a message that boings past its resting place feels like a toy the second time you use it. What is left behind is the outcome rather than another animation of it: the composer opens a quoted line naming who is being answered, cancellable in one tap.

Reply to a message by swipingQuote a chat messageQuick action on a list rowThreaded reply in a conversation

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

    Dragging a bubble sideways reveals a reply arrow and opens a quoted composer on release.

Related patterns