Swipe Back Peel
An edge drag peels the top page away under your finger and snaps to whichever side the gesture was heading for.
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 { useState } from "react";
import {
animate,
motion,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
/**
* Vibary · Swipe Back Peel
*
* Drag from the left edge and the top page peels away under your finger,
* revealing the page behind it as it goes. Let go and it snaps to
* whichever side the gesture was heading for — distance or speed, either
* one can win.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Pages follow the host app's color scheme and everything on them is
* mixed from the inherited text color, so the stack reads correctly on a
* light page and on a dark one.
* Works with zero props; tune via `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SwipeBackPeelProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Notified when the top page is opened or dismissed. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** Fraction of the width that commits the gesture on release. */
threshold: number;
/** Pixels per second that commits it regardless of distance. */
velocity: number;
/** How far the page behind is held back, in percent. */
parallax: number;
dim: number;
};
// Quality rule: this is direct manipulation, so the page must sit exactly
// under the finger — no elasticity, no momentum, no lag. Only the release
// is animated, and its springs are at or above a 0.8 damping ratio so a
// full page never rebounds off the edge of the frame. Nothing scales:
// scaling a page scales every glyph on it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Commits early and settles hard. For flows people leave constantly.
subtle: {
spring: { type: "spring", stiffness: 520, damping: 46 },
threshold: 0.26,
velocity: 380,
parallax: 16,
dim: 0.18,
},
// The platform-standard feel: about a third of the width, or a flick.
default: {
spring: { type: "spring", stiffness: 420, damping: 38 },
threshold: 0.32,
velocity: 460,
parallax: 24,
dim: 0.26,
},
// Asks for a fuller gesture and gives the page behind more travel.
playful: {
spring: { type: "spring", stiffness: 340, damping: 31 },
threshold: 0.4,
velocity: 540,
parallax: 32,
dim: 0.32,
},
};
/** The frame is a fixed size here, so the gesture can be measured against
* a constant. In a fluid layout, read the container width from a ref and
* keep it in state instead. */
const FRAME_WIDTH = 300;
/** Width of the invisible strip that can start the gesture. */
const EDGE = 26;
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ARTICLES = [
{
title: "Quarterly revenue holds at 12% growth",
meta: "Analytics · 4 min read",
body: "Recurring revenue grew for the seventh consecutive quarter, with expansion from existing accounts accounting for most of the increase. Churn stayed flat at 1.4% despite the pricing change in May.",
},
{
title: "What changed in the refund policy",
meta: "Operations · 3 min read",
body: "Refunds now settle to the original payment method within five business days, and partial refunds no longer require a support agent to approve them line by line.",
},
{
title: "Support volume after the new help centre",
meta: "Support · 5 min read",
body: "First-contact resolution rose nine points once the top twenty questions were answered in the help centre, and the requests that remain are markedly more complex.",
},
] as const;
export default function SwipeBackPeel({
variant = "default",
onOpenChange,
}: SwipeBackPeelProps) {
const [open, setOpen] = useState(false);
const [article, setArticle] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const dragControls = useDragControls();
// One motion value is the whole state of the gesture: 0 is the top page
// covering the frame, FRAME_WIDTH is fully peeled away. The drag writes
// to it directly, and the release animates it — so a finger and a spring
// are driving the same number and can hand off mid-flight.
const x = useMotionValue(FRAME_WIDTH);
const listX = useTransform(x, [0, FRAME_WIDTH], [`-${cfg.parallax}%`, "0%"]);
const listDim = useTransform(x, [0, FRAME_WIDTH], [cfg.dim, 0]);
const edgeShadow = useTransform(x, [0, FRAME_WIDTH], [0.24, 0]);
const settle = (to: number, velocity = 0) => {
if (reduceMotion) {
x.set(to);
return;
}
animate(x, to, { ...cfg.spring, velocity });
};
const openArticle = (index: number) => {
setArticle(index);
setOpen(true);
onOpenChange?.(true);
x.set(FRAME_WIDTH);
settle(0);
};
const close = (velocity = 0) => {
if (reduceMotion) {
x.set(FRAME_WIDTH);
setOpen(false);
onOpenChange?.(false);
return;
}
animate(x, FRAME_WIDTH, {
...cfg.spring,
velocity,
onComplete: () => {
setOpen(false);
onOpenChange?.(false);
},
});
};
const onDragEnd = (_event: unknown, info: PanInfo) => {
// Either measure can carry the gesture: a long slow drag, or a short
// fast flick. Requiring both is what makes swipe-back feel sticky.
const committed =
info.offset.x > FRAME_WIDTH * cfg.threshold || info.velocity.x > cfg.velocity;
if (committed) close(info.velocity.x);
else settle(0, info.velocity.x);
};
return (
<div
style={{
position: "relative",
width: FRAME_WIDTH,
height: 400,
borderRadius: 22,
// The pages follow the host app's colour scheme: `Canvas` and
// `CanvasText` are the CSS system colors for page background and
// page text, so a page is opaque and legible in a light app and in
// a dark one. Everything on it mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 18px 44px rgba(0,0,0,0.2)",
// Pages are positioned against this frame rather than the
// viewport, so the pattern drops into a preview or an embedded
// card. For a real app shell, make this the routed region and give
// each page `position: fixed; inset: 0` instead.
overflow: "hidden",
touchAction: "pan-y",
}}
>
{/* The page behind is driven by the same motion value, so it tracks
the finger exactly — mid-gesture you can see how far back you
would land, which is the whole reason this beats a plain button. */}
<motion.div
style={{
position: "absolute",
inset: 0,
x: listX,
display: "flex",
flexDirection: "column",
background: "Canvas",
color: "CanvasText",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
height: 50,
padding: "0 14px",
borderBottom: `1px solid ${tone(12)}`,
fontSize: 13.5,
fontWeight: 650,
}}
>
Reading list
</div>
<div style={{ flex: 1, padding: "6px 8px" }}>
{ARTICLES.map((item, index) => (
<button
key={item.title}
type="button"
onClick={() => openArticle(index)}
style={{
display: "block",
width: "100%",
padding: "11px 10px",
borderRadius: 11,
border: 0,
background: "transparent",
color: "inherit",
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}>
{item.title}
</span>
<span
style={{
display: "block",
fontSize: 11,
opacity: 0.5,
marginTop: 2,
}}
>
{item.meta}
</span>
</button>
))}
</div>
<motion.div
aria-hidden
style={{
position: "absolute",
inset: 0,
background: "#000000",
opacity: listDim,
pointerEvents: "none",
}}
/>
</motion.div>
<motion.div
role="group"
aria-label={ARTICLES[article].title}
aria-hidden={!open}
drag={open ? "x" : false}
dragControls={dragControls}
// The gesture starts from the edge strip below, not from anywhere
// on the page — otherwise every scroll and tap would fight it.
dragListener={false}
dragConstraints={{ left: 0, right: FRAME_WIDTH }}
// No elasticity and no momentum: during the drag the page is under
// the finger, full stop. Everything expressive happens on release.
dragElastic={0}
dragMomentum={false}
onDragEnd={onDragEnd}
style={{
position: "absolute",
inset: 0,
x,
display: "flex",
flexDirection: "column",
background: "Canvas",
color: "CanvasText",
pointerEvents: open ? "auto" : "none",
touchAction: "pan-y",
}}
>
{/* The shadow fades out with the peel, so the two pages separate
while one is over the other and merge as it leaves. */}
<motion.div
aria-hidden
style={{
position: "absolute",
top: 0,
bottom: 0,
left: -24,
width: 24,
background: "linear-gradient(to right, transparent, #000000)",
opacity: edgeShadow,
pointerEvents: "none",
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
height: 50,
padding: "0 12px",
borderBottom: `1px solid ${tone(12)}`,
}}
>
<button
type="button"
onClick={() => close()}
aria-label="Back to reading list"
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: 8,
border: 0,
background: tone(8),
color: ACCENT,
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12.5 4.5 7 10l5.5 5.5" />
</svg>
</button>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>Article</div>
</div>
<div style={{ flex: 1, padding: "14px 16px" }}>
<div style={{ fontSize: 15, fontWeight: 650, lineHeight: 1.35 }}>
{ARTICLES[article].title}
</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 4 }}>
{ARTICLES[article].meta}
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12.5,
lineHeight: 1.65,
opacity: 0.75,
}}
>
{ARTICLES[article].body}
</p>
</div>
{/* The edge strip: invisible, the width of a thumb, and the only
place the gesture can begin. `touchAction: none` stops the
browser claiming the horizontal drag for a scroll. */}
<div
aria-hidden
onPointerDown={(event) => {
if (!open) return;
dragControls.start(event);
}}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: EDGE,
touchAction: "none",
cursor: "grab",
}}
>
<span
style={{
position: "absolute",
left: 5,
top: "50%",
width: 3,
height: 44,
marginTop: -22,
borderRadius: 2,
background: tone(18),
}}
/>
</div>
</motion.div>
</div>
);
}About this pattern
Direct manipulation, which raises the bar: the page has to sit exactly under the finger, so the drag has no elasticity, no momentum and no lag, and everything expressive is saved for the release. One motion value carries the whole gesture — the drag writes to it, the release animates it, and the page behind reads from it — which is why a finger and a spring can hand off mid-flight without a seam, and why you can see how far back you would land before committing. On release either measure can win: a long slow drag past a third of the width, or a short fast flick. Requiring both is what makes a swipe-back feel sticky. The gesture starts only from a thumb-wide edge strip, because a page-wide drag listener fights every scroll and tap on the screen.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
An edge drag pulls the previous page in behind the current one.
Related patterns
- Swipe to ReplyDragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.
- Nested Menu DrillA submenu pushes the parent list aside inside the same panel, and the panel resizes to fit it.
- Split View ResizeDragging the divider resizes both panes live, and releasing it settles the split on the nearest stop.