Pull To Refresh (React)
A mobile pull-to-refresh container for React: damped drag with a threshold, an arrow that rotates into a spinner, onRefresh returning a Promise, a check on completion, plus a desktop button fallback.
A fixed-height scroll container that reveals a round indicator when you pull down with the content scrolled to the top: pull distance = finger travel × resistance, capped at a maximum; the arrow rotates from 0 to 180 degrees as you pull and the card turns dark once you pass the threshold. Release past the threshold and it parks at the threshold, swaps to a spinner and awaits onRefresh(); on completion a check mark holds for 0.6s before it springs back, while a release short of the threshold simply snaps back. Gestures are implemented with pointer events, so mouse drags work too, and a "refresh" button in the top-right corner is provided for desktop, keyboard and screen-reader use; whenever the gesture is not taken over, the list scrolls natively.
npx shadcn@latest add https://webberui.com/r/pull-to-refresh.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PullToRefresh />
Installation
npx shadcn@latest add https://webberui.com/r/pull-to-refresh.jsonOr, once registries are configured in components.json, install it as @webberui/pull-to-refresh.
Usage
import { PullToRefresh } from "@/components/ui/pull-to-refresh";
// Basic: onRefresh returns a Promise; once it resolves, a check shows and the indicator retracts
<PullToRefresh
height={360}
onRefresh={async () => {
const latest = await fetchMessages();
setMessages(latest);
}}
>
<ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
</PullToRefresh>
// Tune the feel, swap the wording, render your own indicator
<PullToRefresh
threshold={80}
maxPull={140}
resistance={0.6}
labels={{ pull: "Pull to load", release: "Release to load", refreshing: "Loading", done: "Done" }}
indicator={({ phase, progress, label }) => (
<div className="rounded-full bg-black px-3 py-1 text-xs text-white">
{phase === "pulling" ? `${Math.round(progress * 100)}%` : label}
</div>
)}
onRefresh={reload}
>
{children}
</PullToRefresh>Props
| Prop | Type | Default | Description |
|---|---|---|---|
onRefresh | () => Promise<void> | — | Required. Called when a refresh is triggered; once the Promise resolves a check shows for 0.6s and the indicator retracts, on rejection it retracts immediately (no check) — handle errors inside the function |
children | React.ReactNode | — | The scrollable content |
threshold | number | 72 | Pull distance (px) needed to trigger a refresh |
maxPull | number | 120 | Maximum pull distance (px), never less than threshold |
resistance | number | 0.5 | Damping factor: finger travel × resistance is the actual pull distance |
disabled | boolean | false | Disable the gesture and the fallback button |
height | number | 360 | Fixed container height (px); the inside is overflow-y-auto |
indicator | (state: PullToRefreshIndicatorState) => React.ReactNode | — | Custom indicator renderer; return null to fall back to the built-in card |
labels | PullToRefreshLabels | see below | Status text per phase: read by the aria-live region and shown directly when reduce motion is on |
showDesktopButton | boolean | true | Show a "refresh" button in the top-right corner as a fallback for mouse, keyboard and screen-reader users |
className | string | — | Forwarded to the outermost container |
PullToRefreshLabels
| Field | Type | Default | Description |
|---|---|---|---|
pull | string | "下拉更新" (pull to refresh) | Pulling, threshold not yet reached |
release | string | "放開更新" (release to refresh) | Threshold reached, releasing will refresh |
refreshing | string | "更新中" (refreshing) | Refreshing (waiting on the onRefresh Promise) |
done | string | "已更新" (updated) | Refresh finished, while the check mark holds |
button | string | "重新整理" (refresh) | aria-label and title of the fallback button |
PullToRefreshIndicatorState
| Field | Type | Default | Description |
|---|---|---|---|
phase | "idle" | "pulling" | "refreshing" | "done" | — | Current phase |
progress | number | — | Pull distance relative to the threshold (0–1; stays at 1 beyond the threshold) |
armed | boolean | — | Whether the threshold has been reached (releasing will refresh) |
pull | number | — | Current pull distance (px, after damping and the cap) |
threshold | number | — | Trigger threshold (px) |
label | string | — | Status text for the current phase (labels.pull while idle) |
The PullToRefreshProps, PullToRefreshPhase, PullToRefreshLabels and PullToRefreshIndicatorState types are also named exports.
How it works
- Take-over rule: on
pointerdownthe inner scroll container must havescrollTopof 0, and the first movement must be downward and mostly vertical, before the gesture is taken over; upward or sideways movement is left entirely to native scrolling, so pulling on a half-scrolled list never triggers by mistake. Mouse drags that start on the scrollbar are always left to the scrollbar - Blocking native scroll:
preventDefaultinsidepointermovecannot stop native scrolling, so a non-passive nativetouchmovelistener is attached as well and callspreventDefaultonly after the take-over (it also runs the direction check itself, so nothing depends on the dispatch order of pointer vs. touch events); while taken over the container getstouch-action: pan-xanduser-select: none - The pull distance is the single animation source (a motion value): the content offset, the indicator's position/opacity/scale and the arrow angle are all derived from it, so dragging never re-renders React; only when a custom
indicatoris supplied is the distance mirrored into state for it - On release: if the distance is ≥
thresholdit springs to the threshold and locks there, thenawaitsonRefresh(); success → check mark holds 600ms → springs back; failure → retracts immediately. No new gestures or button presses are accepted while refreshing or retracting - The
clickthat immediately follows a drag is swallowed so lifting the finger or mouse never hits a button inside the list; the desktop fallback button is not setdisabledwhile refreshing (that would drop focus) and usesaria-busyinstead
Accessibility
- The status text lives in a visually hidden
role="status",aria-live="polite"region, so pulling / release / refreshing / updated changes are read by screen readers; the fallback button points at it viaaria-describedby - The top-right fallback button is a native
buttonwitharia-label,titleand afocus-visiblering, so keyboard and screen-reader users can trigger a refresh without any gesture - When the user has "reduce motion" enabled at the system level, the content no longer follows the finger, the indicator becomes a status-text pill fixed at the top (pull / release / refreshing / updated), the spinner does not spin, and neither the snap-back nor the retract is animated