Overview
Pull the list down from the top to refresh. The pull gets stiffer the further you go, a ring fills toward the release point, and the arrow flips when letting go will refresh. While refreshing, the content holds open around a spinner, then a drawn check (or a shake on failure) shows before it settles back. It works with touch, mouse drag, and scrolling up past the top on a trackpad, and the header has a refresh button and an “Updated 2 min ago” line.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useCallback, useEffect, useRef, useState } from "react";import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform,} from "motion/react";import { cn } from "@/lib/utils"; export type PullRefreshProps = { children: ReactNode; onRefresh: () => void | Promise<void>; onError?: (error: unknown) => void; /** Header title. Leave out to hide the header. */ title?: ReactNode; /** Prompt shown while pulling. */ label?: string; /** Pull distance in px that triggers a refresh. */ threshold?: number; /** Height of the scroll area. */ height?: number | string; className?: string;}; type Phase = "idle" | "pulling" | "ready" | "refreshing" | "done" | "error"; const MAX = 170;const HOLD = 56;const EASE_OUT = [0.23, 1, 0.32, 1] as const;const SETTLE = { type: "spring" as const, stiffness: 380, damping: 34 }; // Rubber band: easy at first, then stiffer the further you pull.const rubber = (d: number) => (1 - 1 / ((d * 0.7) / MAX + 1)) * MAX; function ago(ms: number) { const s = Math.round(ms / 1000); if (s < 45) return "just now"; const m = Math.round(s / 60); if (m < 60) return `${m} min ago`; return `${Math.round(m / 60)} h ago`;} export function PullRefresh({ children, onRefresh, onError, title, label = "Pull to refresh", threshold = 64, height = 360, className,}: PullRefreshProps) { const reduce = !!useReducedMotion(); const scrollRef = useRef<HTMLDivElement>(null); const pull = useMotionValue(0); const [phase, setPhase] = useState<Phase>("idle"); const phaseRef = useRef<Phase>("idle"); const [updatedAt, setUpdatedAt] = useState<number | null>(null); const [, tick] = useState(0); const timer = useRef<number | null>(null); const setP = (p: Phase) => { phaseRef.current = p; setPhase(p); }; // Indicator geometry driven straight from the pull distance. const progress = useTransform(pull, (v) => Math.min(1, v / threshold)); const indicatorY = useTransform(pull, (v) => v / 2 - 18); const indicatorOpacity = useTransform(pull, [0, 18, 40], [0, 0.6, 1]); const indicatorScale = useTransform(pull, [0, threshold], [0.6, 1]); const arrowRotate = useTransform( pull, [threshold * 0.6, threshold], [0, 180], { clamp: true }, ); const contentOpacity = useTransform(pull, [0, MAX], [1, 0.75]); const busy = phase === "refreshing"; const settle = useCallback(() => { if (timer.current) window.clearTimeout(timer.current); timer.current = null; setP("idle"); animate(pull, 0, reduce ? { duration: 0 } : SETTLE); }, [pull, reduce]); const refresh = useCallback(async () => { if (phaseRef.current === "refreshing") return; if (timer.current) window.clearTimeout(timer.current); setP("refreshing"); animate(pull, HOLD, reduce ? { duration: 0 } : SETTLE); try { await onRefresh(); setUpdatedAt(Date.now()); setP("done"); } catch (error) { onError?.(error); setP("error"); } timer.current = window.setTimeout(settle, 900); }, [onError, onRefresh, pull, reduce, settle]); const release = useCallback(() => { const p = phaseRef.current; if (p !== "pulling" && p !== "ready") return; if (pull.get() >= threshold) void refresh(); else settle(); }, [pull, refresh, settle, threshold]); const track = useCallback( (distance: number) => { const v = rubber(Math.max(0, distance)); pull.set(v); const next = v >= threshold ? "ready" : "pulling"; if (phaseRef.current !== next) setP(next); }, [pull, threshold], ); const canPull = () => { const p = phaseRef.current; return ( (scrollRef.current?.scrollTop ?? 1) <= 0 && p !== "refreshing" && p !== "done" && p !== "error" ); }; // Touch: listen natively so the pull can stop the page from scrolling. useEffect(() => { const el = scrollRef.current; if (!el) return; let startY: number | null = null; const onStart = (e: TouchEvent) => { startY = canPull() ? e.touches[0].clientY : null; }; const onMove = (e: TouchEvent) => { if (startY === null) return; const d = e.touches[0].clientY - startY; if (d <= 0 && phaseRef.current === "idle") { startY = null; return; } if (e.cancelable) e.preventDefault(); track(d); }; const onEnd = () => { if (startY === null) return; startY = null; release(); }; el.addEventListener("touchstart", onStart, { passive: true }); el.addEventListener("touchmove", onMove, { passive: false }); el.addEventListener("touchend", onEnd); el.addEventListener("touchcancel", onEnd); return () => { el.removeEventListener("touchstart", onStart); el.removeEventListener("touchmove", onMove); el.removeEventListener("touchend", onEnd); el.removeEventListener("touchcancel", onEnd); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [track, release]); // Trackpad: scrolling up past the top pulls; a short pause in wheel events releases. useEffect(() => { const el = scrollRef.current; if (!el) return; let acc = 0; let idle: number | null = null; let lockedUntil = 0; const onWheel = (e: WheelEvent) => { const now = performance.now(); if (now < lockedUntil) { // Swallow leftover momentum after a release. if (el.scrollTop <= 0 && e.deltaY < 0) { e.preventDefault(); lockedUntil = now + 220; } return; } if (!(acc > 0 || (e.deltaY < 0 && canPull()))) return; e.preventDefault(); acc = Math.max(0, acc - e.deltaY); track(acc); if (idle) window.clearTimeout(idle); idle = window.setTimeout(() => { acc = 0; lockedUntil = performance.now() + 220; release(); }, 160); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("wheel", onWheel); if (idle) window.clearTimeout(idle); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [track, release]); // Mouse: drag the content down from the top. const drag = useRef<{ id: number; y: number } | null>(null); const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => { if (e.pointerType !== "mouse" || e.button !== 0 || !canPull()) return; drag.current = { id: e.pointerId, y: e.clientY }; }; const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; const dist = e.clientY - d.y; if (phaseRef.current === "idle") { if (dist < 6) return; try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} // A pull isn't a text selection. window.getSelection()?.removeAllRanges(); } track(dist); }; const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => { if (drag.current?.id !== e.pointerId) return; drag.current = null; release(); }; useEffect(() => { if (updatedAt === null) return; const id = window.setInterval(() => tick((n) => n + 1), 30_000); return () => window.clearInterval(id); }, [updatedAt]); useEffect( () => () => void (timer.current && window.clearTimeout(timer.current)), [], ); const message = phase === "refreshing" ? "Refreshing…" : phase === "done" ? "Up to date" : phase === "error" ? "Couldn’t refresh" : phase === "ready" ? "Release to refresh" : label; return ( <section aria-busy={busy} className={cn( "relative overflow-hidden rounded-[20px] bg-(--color-paper) shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.18)]", className, )} > {title !== undefined ? ( <div className="relative z-20 flex items-center justify-between gap-3 border-b border-(--color-rule) bg-(--color-paper)/85 px-4 py-2.5 backdrop-blur-md"> <div className="min-w-0"> <div className="truncate text-[14px] font-semibold tracking-tight text-(--color-ink)"> {title} </div> <div className="relative h-4 overflow-hidden text-[11.5px] text-(--color-neutral)"> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={busy ? "busy" : updatedAt === null ? "never" : "at"} initial={{ y: 8, opacity: 0, filter: "blur(2px)" }} animate={{ y: 0, opacity: 1, filter: "blur(0px)" }} exit={{ y: -8, opacity: 0, filter: "blur(2px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} > {busy ? "Checking for updates…" : updatedAt === null ? label : `Updated ${ago(Date.now() - updatedAt)}`} </motion.div> </AnimatePresence> </div> </div> <button type="button" onClick={() => void refresh()} disabled={busy} aria-label="Refresh" className="grid size-8 shrink-0 place-items-center rounded-full text-(--color-ink-2) transition-[background-color,color,transform] outline-none hover:bg-(--color-paper-2) hover:text-(--color-ink) focus-visible:ring-2 focus-visible:ring-(--color-focus) active:scale-90 disabled:cursor-wait" > <motion.svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" animate={busy && !reduce ? { rotate: 360 } : { rotate: 0 }} transition={ busy ? { duration: 0.9, repeat: Infinity, ease: "linear" } : { duration: 0 } } > <path d="M13.2 8a5.2 5.2 0 1 1-1.6-3.75" /> <path d="M13.2 2.6v2.6h-2.6" /> </motion.svg> </button> </div> ) : null} <div className="relative"> {/* Pull indicator, revealed in the gap above the content */} <motion.div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center gap-2" style={{ y: indicatorY, opacity: indicatorOpacity }} > <motion.div style={{ scale: indicatorScale }} animate={ phase === "ready" && !reduce ? { scale: [1, 1.15, 1] } : undefined } transition={{ duration: 0.3 }} className="relative grid size-9 place-items-center rounded-full bg-(--color-paper) text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule),0_4px_12px_-4px_rgba(0,0,0,0.2)]" > <svg viewBox="0 0 36 36" className="absolute inset-0 size-full -rotate-90" > {phase === "refreshing" ? ( <motion.circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeDasharray="22 88" animate={reduce ? undefined : { rotate: 360 }} transition={{ duration: 0.8, repeat: Infinity, ease: "linear", }} /> ) : phase === "pulling" || phase === "ready" || phase === "idle" ? ( <motion.circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" style={{ pathLength: progress }} /> ) : null} </svg> <AnimatePresence initial={false} mode="popLayout"> {phase === "done" ? ( <motion.svg key="done" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ opacity: 0 }} > <motion.path d="M3.5 8.5 6.5 11.5 12.5 4.5" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, ease: EASE_OUT }} /> </motion.svg> ) : phase === "error" ? ( <motion.svg key="err" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" initial={{ opacity: 0 }} animate={{ opacity: 1, x: reduce ? 0 : [0, -3, 3, -2, 2, 0] }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }} > <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" /> </motion.svg> ) : phase !== "refreshing" ? ( <motion.svg key="arrow" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ rotate: arrowRotate }} exit={{ opacity: 0, scale: 0.6 }} > <path d="M8 3v10M4 9l4 4 4-4" /> </motion.svg> ) : null} </AnimatePresence> </motion.div> <div className="relative h-4 min-w-28 text-[12px] font-medium text-(--color-ink-2)"> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={message} initial={{ opacity: 0, y: 4, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -4, filter: "blur(3px)" }} transition={{ duration: 0.2 }} className="whitespace-nowrap" > {message} </motion.div> </AnimatePresence> </div> </motion.div> <span className="sr-only" aria-live="polite"> {phase === "refreshing" ? "Refreshing" : phase === "done" ? "Up to date" : phase === "error" ? "Refresh failed" : ""} </span> <div ref={scrollRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onDragStart={(e) => drag.current && e.preventDefault()} className={cn( "relative overflow-y-auto overscroll-contain", phase === "pulling" || phase === "ready" ? "select-none" : "", )} style={{ height }} > <motion.div style={{ y: pull, opacity: contentOpacity }} className="p-3" > {children} </motion.div> </div> </div> </section> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { PullRefresh } from "@/components/ui/pull-refresh"; <PullRefresh title="Inbox" onRefresh={() => refetch()} height={400}> <MessageList messages={messages} /></PullRefresh>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | ReactNode | Yes | — | Scrollable content. |
| onRefresh | () => void | Promise<void> | Yes | — | Runs on release past the threshold or from the button. The spinner shows until the promise settles. |
| onError | (error: unknown) => void | No | — | Called when onRefresh rejects. |
| title | ReactNode | No | — | Header title. Leave out to hide the header and its refresh button. |
| label | string | No | "Pull to refresh" | Prompt shown while pulling. |
| threshold | number | No | 64 | Pull distance in pixels that triggers a refresh. |
| height | number | string | No | 360 | Height of the scroll area. |
| className | string | No | — | Classes for the card. |
Best Practices
- Keep the header (
title) whenever possible: it gives keyboard and mouse users a visible way to refresh. - Return a promise from
onRefreshso the spinner lasts as long as the request. - Animate new items in at the top so people can see what changed.
- Reduced motion keeps the gesture but drops the springs and spinning.