Swipe to Reply
Dragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
Dragging a bubble sideways reveals a reply arrow and opens a quoted composer on release.
Related patterns
- Swipe Back PeelAn edge drag peels the top page away under your finger and snaps to whichever side the gesture was heading for.
- Share Sheet PresentThe panel rises over a dimmed page and its destinations arrive in a quick wave.
- Message Reaction AttachThe chosen glyph flies out of the picker and docks at the corner of the bubble it belongs to.