WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/pull-to-refresh.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

72
0.5
360
<PullToRefresh />

Installation

npx shadcn@latest add https://webberui.com/r/pull-to-refresh.json

Or, 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

PropTypeDefaultDescription
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
childrenReact.ReactNodeThe scrollable content
thresholdnumber72Pull distance (px) needed to trigger a refresh
maxPullnumber120Maximum pull distance (px), never less than threshold
resistancenumber0.5Damping factor: finger travel × resistance is the actual pull distance
disabledbooleanfalseDisable the gesture and the fallback button
heightnumber360Fixed container height (px); the inside is overflow-y-auto
indicator(state: PullToRefreshIndicatorState) => React.ReactNodeCustom indicator renderer; return null to fall back to the built-in card
labelsPullToRefreshLabelssee belowStatus text per phase: read by the aria-live region and shown directly when reduce motion is on
showDesktopButtonbooleantrueShow a "refresh" button in the top-right corner as a fallback for mouse, keyboard and screen-reader users
classNamestringForwarded to the outermost container

PullToRefreshLabels

FieldTypeDefaultDescription
pullstring"下拉更新" (pull to refresh)Pulling, threshold not yet reached
releasestring"放開更新" (release to refresh)Threshold reached, releasing will refresh
refreshingstring"更新中" (refreshing)Refreshing (waiting on the onRefresh Promise)
donestring"已更新" (updated)Refresh finished, while the check mark holds
buttonstring"重新整理" (refresh)aria-label and title of the fallback button

PullToRefreshIndicatorState

FieldTypeDefaultDescription
phase"idle" | "pulling" | "refreshing" | "done"Current phase
progressnumberPull distance relative to the threshold (0–1; stays at 1 beyond the threshold)
armedbooleanWhether the threshold has been reached (releasing will refresh)
pullnumberCurrent pull distance (px, after damping and the cap)
thresholdnumberTrigger threshold (px)
labelstringStatus 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 pointerdown the inner scroll container must have scrollTop of 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: preventDefault inside pointermove cannot stop native scrolling, so a non-passive native touchmove listener is attached as well and calls preventDefault only 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 gets touch-action: pan-x and user-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 indicator is supplied is the distance mirrored into state for it
  • On release: if the distance is ≥ threshold it springs to the threshold and locks there, then awaits onRefresh(); success → check mark holds 600ms → springs back; failure → retracts immediately. No new gestures or button presses are accepted while refreshing or retracting
  • The click that 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 set disabled while refreshing (that would drop focus) and uses aria-busy instead

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 via aria-describedby
  • The top-right fallback button is a native button with aria-label, title and a focus-visible ring, 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

On this page