Overlays & Feedback

Pull To Refresh

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.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add pull-to-refresh.en

New project or installation trouble? Start here →

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
Active edited usageDefault props
<PullToRefresh/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/pull-to-refresh.en.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.ReactNode—The 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.ReactNode—Custom 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
classNamestring—Forwarded to the outermost container

PullToRefreshLabels

FieldTypeDefaultDescription
pullstring"Update Down" (pull to refresh)Pulling, threshold not yet reached
releasestring"Unlock Update" (release to refresh)Threshold reached, releasing will refresh
refreshingstring"Updating" (refreshing)Refreshing (waiting on the onRefresh Promise)
donestring"Updated" (updated)Refresh finished, while the check mark holds
buttonstring"Refresh" (refresh)aria-label and title of the fallback button

PullToRefreshIndicatorState

FieldTypeDefaultDescription
phase"idle" | "pulling" | "refreshing" | "done"—Current phase
progressnumber—Pull distance relative to the threshold (0–1; stays at 1 beyond the threshold)
armedboolean—Whether the threshold has been reached (releasing will refresh)
pullnumber—Current pull distance (px, after damping and the cap)
thresholdnumber—Trigger threshold (px)
labelstring—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 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