Pull to Refresh
Dragging past a threshold arms the refresh, and the indicator tracks the finger the whole way.
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 { useEffect, useRef, useState } from "react";
import {
animate,
AnimatePresence,
motion,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · Pull to Refresh
*
* The indicator is driven by the drag itself: the arc grows with the
* distance travelled, flips to "armed" at the threshold, and holds while
* the request runs. Nothing here is a canned clip — until the finger
* lifts, every frame is a function of where the finger is.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `onRefresh` to run your own fetch.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PullToRefreshProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your fetch. Resolve it when the new data is in hand. */
onRefresh?: () => Promise<unknown> | void;
/** Stand-in duration used only while `onRefresh` is omitted, in ms. */
refreshMs?: number;
/** Panel width — px number or any CSS length. */
width?: number | string;
/** Armed/active color. A state color, so it stays literal. */
accent?: string;
};
type VariantConfig = {
/** Travel needed before the gesture arms, in px. */
threshold: number;
/** Where the panel rests while the request runs, in px. */
hold: number;
/** Rubber-band resistance on the downward drag. */
elastic: number;
/** Seconds per turn of the active arc. */
spinSeconds: number;
/** Entry travel for a newly arrived row, in px. */
rowLift: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: every spring here is at or above a 0.8 damping ratio. A
// pull-to-refresh that boings back past its rest position feels like a
// toy the second time you use it, and this gesture gets used constantly.
// Variants differ in how far you pull and how fast it settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Short pull, tight snap. For dense inboxes on desktop-sized panels.
subtle: {
threshold: 54,
hold: 44,
elastic: 0.46,
spinSeconds: 1,
rowLift: 4,
spring: { type: "spring", stiffness: 560, damping: 48 },
},
// The all-purpose setting: a pull you have to mean, a settle you can see.
default: {
threshold: 68,
hold: 54,
elastic: 0.56,
spinSeconds: 0.85,
rowLift: 8,
spring: { type: "spring", stiffness: 480, damping: 42 },
},
// Longer travel and a softer landing — for a full-screen mobile feed.
playful: {
threshold: 82,
hold: 62,
elastic: 0.66,
spinSeconds: 0.72,
rowLift: 12,
spring: { type: "spring", stiffness: 400, damping: 34 },
},
};
const ACCENT = "#4C7DF0";
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields surfaces and borders that are correctly
* toned in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Row = { id: number; title: string; meta: string };
const SEED_ROWS: Row[] = [
{ id: 4, title: "Invoice #2481 approved", meta: "Finance · 14 min ago" },
{ id: 3, title: "Contract renewal drafted", meta: "Legal · 41 min ago" },
{ id: 2, title: "Q3 forecast shared", meta: "Analytics · 2 h ago" },
{ id: 1, title: "Seat count updated", meta: "Billing · 5 h ago" },
];
const INCOMING_ROWS: Row[] = [
{ id: 5, title: "Export finished", meta: "Reports · just now" },
{ id: 6, title: "New ticket assigned", meta: "Support · just now" },
{ id: 7, title: "Payout scheduled", meta: "Billing · just now" },
];
export default function PullToRefresh({
variant = "default",
onRefresh,
refreshMs = 1300,
width = 328,
accent = ACCENT,
}: PullToRefreshProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const y = useMotionValue(0);
const [armed, setArmed] = useState(false);
const [busy, setBusy] = useState(false);
const [rows, setRows] = useState<Row[]>(SEED_ROWS);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
// One subscription instead of a re-render per frame: the component only
// re-renders on the two frames where the gesture crosses the threshold.
useMotionValueEvent(y, "change", (value) => {
if (!busy) setArmed(value >= cfg.threshold);
});
// Everything the indicator does during the drag is a projection of the
// drag distance. useTransform clamps at both ends, so overpulling past
// the threshold changes nothing — the gesture is already armed.
const shellOpacity = useTransform(y, [2, cfg.threshold * 0.4], [0, 1]);
const shellScale = useTransform(y, [0, cfg.threshold], [0.74, 1]);
const shellShift = useTransform(y, [0, cfg.hold], [-12, 0]);
const arcLength = useTransform(y, [6, cfg.threshold], [0.05, 1]);
const arrowTurn = useTransform(y, [cfg.threshold * 0.55, cfg.threshold], [0, 180]);
const settle = reduceMotion
? { duration: 0.18, ease: "easeOut" as const }
: cfg.spring;
const runRefresh = async () => {
setBusy(true);
setArmed(false);
animate(y, cfg.hold, settle);
try {
if (onRefresh) await onRefresh();
else await new Promise((resolve) => setTimeout(resolve, refreshMs));
} finally {
if (alive.current) {
setRows((current) => {
const next = INCOMING_ROWS[current.length - SEED_ROWS.length];
return next ? [next, ...current] : current;
});
setBusy(false);
animate(y, 0, settle);
}
}
};
const label = busy
? "Refreshing"
: armed
? "Release to refresh"
: "Pull to refresh";
return (
<div
style={{
width,
position: "relative",
overflow: "hidden",
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
// Without this the browser claims the vertical gesture first and
// the drag never reaches the component on a touch screen.
touchAction: "pan-x",
}}
>
{/* The indicator lives behind the panel and is uncovered by the
drag, which is why it can't be a clip: it is only ever as
visible as the gesture has made it. */}
<motion.div
aria-hidden
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: cfg.hold,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
opacity: shellOpacity,
y: shellShift,
}}
>
<motion.div
style={{
scale: shellScale,
width: 22,
height: 22,
display: "grid",
placeItems: "center",
}}
>
<svg width={22} height={22} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.5" stroke={tone(14)} strokeWidth="2" />
{/* Drag-tracked arc: pathLength is the gesture, expressed as a
number between 0 and 1. */}
<motion.circle
cx="12"
cy="12"
r="9.5"
stroke={armed ? accent : tone(38)}
strokeWidth="2"
strokeLinecap="round"
transform="rotate(-90 12 12)"
style={{ pathLength: arcLength, opacity: busy ? 0 : 1 }}
/>
<motion.g
style={{ transformOrigin: "12px 12px" }}
animate={busy && !reduceMotion ? { rotate: 360 } : { rotate: 0 }}
transition={
busy && !reduceMotion
? { duration: cfg.spinSeconds, repeat: Infinity, ease: "linear" }
: { duration: 0 }
}
>
{/* Active arc: a quarter of the 59.7px circumference,
revealed only once the request is in flight. Reduced
motion gets the closed ring, since nothing will turn. */}
<motion.circle
cx="12"
cy="12"
r="9.5"
stroke={accent}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={reduceMotion ? undefined : "15.5 44.2"}
transform="rotate(-90 12 12)"
animate={{ opacity: busy ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
/>
</motion.g>
<motion.path
d="M12 7.6v8.8M8.6 13l3.4 3.4L15.4 13"
stroke={armed ? accent : tone(42)}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
style={{
rotate: reduceMotion ? 0 : arrowTurn,
transformOrigin: "12px 12px",
}}
animate={{ opacity: busy ? 0 : 1 }}
transition={{ duration: 0.16, ease: "easeOut" }}
/>
</svg>
</motion.div>
{/* Fixed-size label slot: the wording changes, the type never
moves or resizes. */}
<div style={{ position: "relative", width: 118, height: 15 }}>
<AnimatePresence initial={false}>
<motion.span
key={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 12,
lineHeight: "15px",
fontWeight: 500,
color: armed || busy ? accent : "inherit",
opacity: 0.85,
whiteSpace: "nowrap",
}}
>
{label}
</motion.span>
</AnimatePresence>
</div>
</motion.div>
{/* The sheet the user actually drags. Constraints pin it at rest;
the elasticity is what makes the pull feel like resistance
rather than free travel. */}
<motion.div
drag={busy ? false : "y"}
dragDirectionLock
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: cfg.elastic }}
dragMomentum={false}
onDragEnd={() => {
if (y.get() >= cfg.threshold) void runRefresh();
else animate(y, 0, settle);
}}
style={{
y,
position: "relative",
background: tone(6),
borderRadius: 16,
cursor: busy ? "default" : "grab",
}}
aria-busy={busy}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "13px 16px 11px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.4 }}>
ACTIVITY
</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>{rows.length} items</span>
</div>
<div style={{ padding: "4px 0 6px" }}>
{rows.map((row, index) => (
<motion.div
key={row.id}
// layout="position" moves the row without stretching it, so
// the type inside never scales as the list grows.
layout={reduceMotion ? false : "position"}
initial={
index === 0 && row.id > SEED_ROWS.length
? { opacity: 0, y: reduceMotion ? 0 : -cfg.rowLift }
: false
}
animate={{ opacity: 1, y: 0 }}
transition={{
opacity: { duration: 0.26, ease: "easeOut" },
y: cfg.spring,
layout: cfg.spring,
}}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "9px 16px",
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 3,
flexShrink: 0,
background: row.id > SEED_ROWS.length ? accent : tone(22),
}}
/>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 13,
fontWeight: 500,
lineHeight: 1.3,
}}
>
{row.title}
</span>
<span
style={{ display: "block", fontSize: 11.5, opacity: 0.52, marginTop: 2 }}
>
{row.meta}
</span>
</span>
</motion.div>
))}
</div>
</motion.div>
</div>
);
}About this pattern
The gesture people already know from every mobile feed, built the way it has to be built: the arc, the scale and the arrow are all projections of the drag distance, so the indicator is wherever the finger left it rather than wherever a timeline says it should be. Crossing the threshold arms it — the arc closes, the arrow turns, the wording changes — and releasing early rubber-bands straight back with nothing spent. Only after release does the component take over: it holds the panel open, spins a quarter arc while the request runs, and collapses once the new row has landed. Because the pull is elastic and the snap-back is over-damped, the panel never overshoots its rest position, which is what separates a gesture that feels attached to the finger from one that feels like a video being scrubbed.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
The message list stretches under the finger and holds a spinner open while it checks for new mail.
Related patterns
- Swipe to ReplyDragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.
- Optimistic Row InsertThe new entry lands at half opacity the instant it is asked for, then firms up when the server agrees.
- Slider Drag ValueThe handle stays under the finger while held, with a value bubble that rises on grab.