Skip to content
WANCRAFT

D.195Navigation & Menus

Pull to Refresh

Pull a scroll container down at the top to refresh: rubber-band resistance, a filling ring, a spinner and a Refresh button.

NewReactMotionSVGTailwindadvanced

Overview

Pull to Refresh brings the native mobile gesture to any scroll container. When the list is at the very top, a downward drag — touch, pen or mouse — pulls the content with exponential rubber-band resistance while a ring fills and an arrow turns to point up as you pass the threshold. Release there and the content rests open while your async onRefresh runs under a spinner; when it settles a check appears, the fresh content fades up into place and everything springs back (a rejected promise shows an error instead). Horizontal drags and drags anywhere but the top are left alone, so normal scrolling never fights the gesture. A visible Refresh button in the toolbar runs exactly the same flow for keyboard and mouse users, and a polite live region announces "Refreshing" and "Updated".

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/pull-to-refresh.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/pull-to-refresh.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/pull-to-refresh.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { PullToRefresh } from "@/components/ui/pull-to-refresh";
import * as React from "react";

export function PullToRefreshExample() {
  const [items, setItems] = React.useState(["Morning brief", "Studio notes"]);
  const refresh = async () => {
    const latest = await fetch("/api/feed").then((r) => r.json() as Promise<string[]>);
    setItems(latest);
  };

  return (
    <div className="h-[560px] w-full max-w-sm rounded-2xl border">
      <PullToRefresh onRefresh={refresh} title="Feed">
        <ul>
          {items.map((item) => (
            <li key={item} className="border-b px-4 py-3">{item}</li>
          ))}
        </ul>
      </PullToRefresh>
    </div>
  );
}

03Props

PropTypeDefaultDescription
childrenReactNode—Scrollable content.
onRefresh() => Promise<unknown> | void—Required. Runs on release past the threshold or from the Refresh button; the spinner shows until it settles.
thresholdnumber72Pull distance in px that arms a refresh (capped at 85% of maxPull).
maxPullnumber128Distance in px the rubber band approaches.
resistancenumber1Higher values need a longer drag for the same pull.
titleReactNode—Left side of the toolbar.
showRefreshButtonbooleantrueVisible Refresh button in the toolbar.
labelstring"Feed"Accessible name of the scroll region.
updatedTextstring"Updated"Shown and announced after a successful refresh.
errorTextstring"Couldn't refresh"Shown and announced when onRefresh rejects.
accentstring"#FF7448"Ring, spinner and focus colour.
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 380, damping: 32, mass: 1 }Settle and spring-back spring.
scrollClassNamestring—Classes for the scroll container.
...propsHTMLAttributes<HTMLDivElement>—Forwarded to the root (give it a height).

04Features

  • 01Pull only at scrollTop 0, only for downward, mostly vertical drags: normal scrolling is untouched
  • 02Exponential rubber-band resistance approaching a maximum pull
  • 03Ring fills and an arrow turns with pull distance; label flips to Release to refresh
  • 04Async onRefresh with a spinner, success check or error, then a subtle content reveal
  • 05Touch via non-passive touchmove (blocks overscroll only while pulling); mouse and pen via pointer capture
  • 06Toolbar Refresh button runs the same flow
motion · springmotion · tweeninput · draginput · scrollinput · clickinput · keyboard

05Accessibility

  • A visible Refresh button (a native button, aria-disabled while busy) gives keyboard and mouse users the same action without dragging.
  • A polite live region announces "Refreshing" and then "Updated" or the error text.
  • The scroll container is a focusable, labelled region with aria-busy while refreshing, so it can be scrolled with the keyboard.
  • The pull indicator is aria-hidden; its state is spoken by the live region.
  • The click that ends a mouse pull is swallowed so it doesn't activate content.
  • Reduced motion: no spinner rotation or reveal, and settling is a short tween instead of a spring.