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 motion

Add 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:

components/ui/pull-refresh.tsx
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

PropTypeRequiredDefaultDescription
childrenReactNodeYes—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) => voidNo—Called when onRefresh rejects.
titleReactNodeNo—Header title. Leave out to hide the header and its refresh button.
labelstringNo"Pull to refresh"Prompt shown while pulling.
thresholdnumberNo64Pull distance in pixels that triggers a refresh.
heightnumber | stringNo360Height of the scroll area.
classNamestringNo—Classes for the card.

Best Practices

  1. Keep the header (title) whenever possible: it gives keyboard and mouse users a visible way to refresh.
  2. Return a promise from onRefresh so the spinner lasts as long as the request.
  3. Animate new items in at the top so people can see what changed.
  4. Reduced motion keeps the gesture but drops the springs and spinning.