# React Pull to Refresh in a PWA Without Fighting the Browser

> Build a React pull to refresh for a PWA: overscroll-behavior, rubberband resistance, iOS spokes or a Material ring, a haptic tick and a refresh button.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-pull-to-refresh

## TL;DR

A React pull to refresh that holds up in a PWA needs four things: overscroll-behavior-y: contain on the scroller so Chrome does not reload the page, a touchmove listener added with { passive: false } because React's onTouchMove is passive and Safari on iOS ignores overscroll-behavior for its own refresh, a rubberband curve instead of a linear drag, and a refresh button for people who cannot drag. Pick an indicator that matches the platform (iOS spokes that move the list down, or a Material ring that floats over it) and fire one haptic tick at the threshold as an extra, never as the only signal. The Pro Wingo UI Pull To Refresh does all of this in one component.

A React pull to refresh looks like forty lines of touch handlers until it runs in a phone browser. Chrome on Android and Safari on iOS have their own pull to refresh that reloads the page, React registers `onTouchMove` as passive so `preventDefault()` does nothing, and a list that follows the finger one to one feels wrong on a phone. This guide fixes each one for a PWA, with the raw pattern and the Wingo UI [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) that packages it.

## Why does a PWA need its own pull to refresh?

Because the browser's version reloads the document, and once the app is installed it may not exist at all. A reload runs every script again and drops all in-memory state: fetched data, an open sheet, the scroll position of a nested list. A data refresh only refetches the list. And a web app added to the iPhone home screen opens in standalone mode, without Safari's toolbar or its pull to refresh (our [React PWA install prompt tutorial](https://wingo-ui.com/blog/react-pwa-install-prompt) covers how people get it there). Discourse shipped a theme component to add one back; its [December 2024 announcement](https://meta.discourse.org/t/ios-pwa-app-pull-to-refresh/343262) opens with "Pull to refresh is a huge gap in iOS PWA."

Ready-made packages are thin. As of October 2026, a search for pull to refresh react pwa still puts [@patlux/react-pull-to-refresh](https://www.npmjs.com/package/@patlux/react-pull-to-refresh) (last published November 2018) and [react-js-pull-to-refresh](https://www.npmjs.com/package/react-js-pull-to-refresh) (last published August 2022) near the top, next to React Native packages that do not run in a browser. Android calls the gesture swipe to refresh, so a React swipe to refresh component is the same thing under another name.

## How do you stop the browser's own pull to refresh?

Set `overscroll-behavior-y: contain` on the element that scrolls: `html` when the window scrolls, the container otherwise. That one line is the whole overscroll-behavior pull to refresh fix in Chrome, which has supported the property since [Chrome 63](https://developer.chrome.com/blog/overscroll-behavior); `contain` keeps the overscroll glow and `none` removes it too. Per [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior), `contain` "disables native browser navigation, including the vertical pull-to-refresh gesture".

Safari is the exception. It has supported the property since Safari 16 ([caniuse](https://caniuse.com/css-overscroll-behavior)), but [WebKit bug 275947](https://bugs.webkit.org/show_bug.cgi?id=275947), filed in June 2024 and still open when we checked in October 2026, reports that neither `none` nor `contain` turns off Safari's pull to refresh on iOS. What works there is JavaScript: once a touch at the top moves down, call `preventDefault()` on its `touchmove`, so the page does not scroll, bounce or start Safari's refresh under the finger.

That is where React gets in the way. The [React 17 changelog](https://legacy.reactjs.org/blog/2020/10/20/react-v17.html) lists "Keep `onTouchStart`, `onTouchMove`, and `onWheel` passive." A passive listener cannot cancel the event, so `onTouchMove={(e) => e.preventDefault()}` is silently ignored. Attach the listener yourself, on the scroller:

```ts
// React registers onTouchMove as a passive listener, so preventDefault() inside it is ignored.
// attach it yourself, on the scroller (on document, every scroll on the page would wait for this handler)
export function attachPull(scroller: HTMLElement, onPull: (dy: number) => void, onRelease: () => void) {
  let startY: number | null = null;
  let pulling = false;

  const start = (event: TouchEvent) => {
    pulling = false;
    startY = event.touches.length === 1 && scroller.scrollTop <= 0 ? event.touches[0].clientY : null;
  };
  const move = (event: TouchEvent) => {
    if (startY === null) return;
    const dy = event.touches[0].clientY - startY;
    if (!pulling && dy < 0) {
      // the finger went up first: a normal scroll, hand it back
      startY = null;
      return;
    }
    pulling = true;
    event.preventDefault(); // allowed because the listener is not passive
    onPull(Math.max(0, dy));
  };
  const end = () => {
    if (pulling) onRelease();
    startY = null;
    pulling = false;
  };

  const previous = scroller.style.overscrollBehaviorY;
  scroller.style.overscrollBehaviorY = "contain";
  scroller.addEventListener("touchstart", start, { passive: true });
  scroller.addEventListener("touchmove", move, { passive: false });
  scroller.addEventListener("touchend", end);
  scroller.addEventListener("touchcancel", end);
  return () => {
    scroller.style.overscrollBehaviorY = previous;
    scroller.removeEventListener("touchstart", start);
    scroller.removeEventListener("touchmove", move);
    scroller.removeEventListener("touchend", end);
    scroller.removeEventListener("touchcancel", end);
  };
}
```

Call it from a `useEffect` and return its cleanup. Write `dy` to a motion value or a ref, not to state, or every `touchmove` re-renders the list. It is a skeleton. The Wingo UI component adds what it misses: it waits 8px before deciding whether a touch is a pull, drops gestures that are more sideways than down, cancels on a second finger (a pinch is never a pull), and never starts in an input, inside `[data-no-pull]`, in a horizontal scroller or in a nested list that is not at its own top.

## How much resistance should a pull to refresh have?

Resistance that grows the further the finger goes, so the list slows down smoothly instead of following one to one into a hard stop. [react-simple-pull-to-refresh](https://www.npmjs.com/package/react-simple-pull-to-refresh), the most recently released web package we looked at (1.3.4, January 5, 2026, MIT, React 16.10 to 19), divides the finger travel by a `resistance` prop (default 1) and clamps it at `maxPullDownDistance` (default 95px): a linear pull into a wall. With no dependencies, it is a fair pick for a prototype.

A native list follows a curve. The `rubberband` helper in Wingo UI's `lib/motion` uses an iOS-style one, `(1 - 1 / (x * 0.55 / d + 1)) * d`, where `x` is the finger travel past the 8px slop and `d` the scroller height (480px at least, so a short box resists like a phone-sized one). On a 480px scroller with the defaults, 100px of finger travel moves the list 49px, 200px moves it 89px, the refresh arms at 72px (`threshold`) after about 154px, and the list never passes `maxPull` (128px). The pull lives in a motion value, so a `touchmove` never re-renders your list; React state changes only when the phase does (pulling, armed, refreshing, done).

A release before the threshold springs back with the finger's velocity. A release past it parks the list at the threshold while `onRefresh` runs (500ms at least, so the spinner never flashes), then shows a check, or an alert when the promise rejects, for `doneDuration` (600ms) before the list glides back. Under reduced motion the list jumps to each resting point instead of springing.

## Arrow, iOS spokes or a Material ring: which indicator should you use?

Match the platform most of your users hold, and decide whether the content should move. The three variants of the component:

| `variant` | The list | While pulling | While refreshing | Good fit |
| --- | --- | --- | --- | --- |
| `arrow` (default) | moves down | an arrow that turns at the threshold | a spinner | neutral apps on both platforms |
| `ios` | moves down | 12 spokes that draw one by one | the spokes spin in 12 steps, like UIRefreshControl | iPhone-first apps |
| `material` | stays put | a floating circle whose ring fills | a spinning ring in the circle | Android-first apps, image grids, virtual lists |

`size` sets the glyph to 20, 24 or 28px (the Material circle to 36, 40 or 44px), `tone` colors it (neutral by default), and `showLabel` adds a line that reads Pull to refresh, Release to refresh, Refreshing…, then Updated. Under reduced motion the spokes spin at half speed.

> Live demo (Pull To Refresh): Pull the inbox down with a finger, or scroll up past the top on a trackpad: the spokes draw as you pull, the label flips to Release to refresh at the threshold, and new messages land on top. Try it at [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) and install it with `npx wingo-ui@latest add pull-to-refresh`.

Here is a full inbox screen with the iOS variant around the [List](https://wingo-ui.com/components/list). `scroll="self"` makes the component the scroll container, with `overscroll-behavior-y: contain` on itself. With the default `scroll="parent"` it watches the scroller you pass or provide, else the nearest scrolling ancestor, else the window, and sets the property there while it is mounted and enabled.

```tsx
"use client";

import { useState } from "react";
import { RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { List, type ListItemData } from "@/components/ui/list";
import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh";

type InboxProps = { initial: ListItemData[]; load: () => Promise<ListItemData[]> };

export function Inbox({ initial, load }: InboxProps) {
  const [items, setItems] = useState(initial);
  const [api, setApi] = useState<PullToRefreshApi>();

  return (
    <div className="flex h-dvh flex-col">
      <header className="flex h-14 shrink-0 items-center justify-between px-4">
        <h1 className="text-lg font-semibold">Inbox</h1>
        {/* the same refresh for keyboards, switch access and mouse users */}
        <Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() => void api?.refresh()}>
          <RotateCw />
        </Button>
      </header>
      <PullToRefresh
        scroll="self"
        variant="ios"
        shortcut="shift+r"
        className="min-h-0 flex-1"
        setApi={setApi}
        onRefresh={async () => setItems(await load())}
        labels={{ refreshing: "Checking for mail…", done: "Up to date", error: "Could not check for mail" }}
      >
        <List variant="plain" items={items} />
      </PullToRefresh>
    </div>
  );
}
```

The indicator spins until the `onRefresh` promise settles; a rejection shows the error state. Pass `refreshing` when the app starts a reload on its own, and `disabled` while the user edits or filters: turning it on mid-pull springs the list back without refreshing.

## How do you add a haptic tick to a pull to refresh?

Fire one short tick when the pull crosses the threshold, the moment letting go starts to mean refresh; never on every frame or on release. The component calls `haptic("medium")` from the [haptics helper](https://wingo-ui.com/components/haptics) when the pull arms and `haptic("error")` when a pulled refresh fails. `haptics={false}` turns both off, and `configureHaptics({ enabled })` is the app-wide switch.

Treat the tick as a bonus, because support is uneven. As of October 2026, [caniuse](https://caniuse.com/vibration) lists `navigator.vibrate` in Chrome for Android and Samsung Internet, removed from Firefox in version 129 and never in Safari. On iPhone the helper clicks a hidden `<input type="checkbox" switch>` from script to play the system tick; the switch arrived in [Safari 17.4](https://webkit.org/blog/15054/an-html-switch-control/), and WebKit's announcement does not mention a haptic, so nothing promises it will last. [ios-haptics](https://www.npmjs.com/package/ios-haptics), built on the same trick, said in its June 2026 README (version 3.1.1) that it "only works on ios 17.4 to 26.4, as apple patched it in ios 26.5". Its September 2026 release (3.2.0) forwards the user's own tap to the switch as a trusted click instead, which suggests a click from script no longer plays. A pull never produces a tap, so expect the tick to stay silent on iOS 26.5 and later, and never put information in it that the indicator does not show.

Vibration also needs [sticky user activation](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/User_activation), and `touchmove` is not an activation event (`touchend`, a finger's `pointerup`, `mousedown` and `keydown` are). If a pull is the first thing someone does on a fresh page, that tick is silent; `haptic()` returns `false` instead of letting Chrome log a blocked call. Our [Web Vibration API guide](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) covers the rest of the platform gap and a setting that turns haptics off.

## How do you make a custom pull to refresh accessible?

Give the same refresh a button, a key and an announcement, because a pull is a dragging movement. [WCAG 2.2 success criterion 2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) (level AA) exempts the browser's own drag to refresh, since the user agent provides it, but applies once your content implements its own, so you need a single-pointer alternative. Android's [swipe to refresh guide](https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface) asks the same of native apps: a refresh action in the app bar's overflow menu, for people who use a keyboard or a D-pad.

The component has a path for each input. `setApi` hands you `{ refresh() }`, which runs exactly what a pull runs (indicator, promise, done state), so a header button and a pull never drift apart; a call during a running refresh returns the running promise. `shortcut` binds a key such as `shift+r`. A polite live region announces Refreshing…, then Updated or the error, and the root carries `aria-busy` meanwhile. On a laptop, scrolling up once more at the very top with a trackpad or wheel pulls (`wheel`, on by default; momentum that carries a scroll to the top never does), and `pullWithMouse` lets a mouse drag pull, for kiosks. The header button in the inbox example uses the Button's `size="icon"`, which is 40px with a mouse and 44px on touch screens, the size the [minimum touch target guide](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind) recommends.

> Live demo (Pull To Refresh): With a mouse, drag the list down (pullWithMouse is on in this demo); on a phone, pull it. The ring fills while the rows stay put, and the Reload button in the header runs the same refresh without a gesture. Try it at [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) and install it with `npx wingo-ui@latest add pull-to-refresh`.

## How do you use pull to refresh with a long virtualized list?

Let one element scroll and share it, so both components watch the same scroll position. The [Virtual List](https://wingo-ui.com/components/virtual-list) with `scroll="window"` follows the nearest `ScrollContainerProvider`, and Pull To Refresh with its default `scroll="parent"` does too:

```tsx
"use client";

import { useState } from "react";
import { ListItem } from "@/components/ui/list";
import { PullToRefresh } from "@/components/ui/pull-to-refresh";
import { VirtualList } from "@/components/ui/virtual-list";
import { ScrollContainerProvider } from "@/hooks/use-scroll-state";

type Order = { id: string; customer: string; city: string; total: string };

type OrdersProps = {
  orders: Order[];
  hasMore: boolean;
  reload: () => Promise<void>;
  loadPage: () => Promise<void>;
};

export function Orders({ orders, hasMore, reload, loadPage }: OrdersProps) {
  // one element scrolls, and both components follow it through the provider
  const [scroller, setScroller] = useState<HTMLDivElement | null>(null);

  return (
    <div ref={setScroller} className="h-dvh overflow-y-auto">
      <ScrollContainerProvider value={scroller}>
        <PullToRefresh variant="material" onRefresh={reload}>
          <VirtualList
            aria-label="Orders"
            scroll="window"
            items={orders}
            renderItem={(order) => <ListItem id={order.id} title={order.customer} description={order.city} value={order.total} />}
            hasMore={hasMore}
            onLoadMore={loadPage}
          />
        </PullToRefresh>
      </ScrollContainerProvider>
    </div>
  );
}
```

Use the material variant here. The Virtual List measures where it starts inside the scroller with `getBoundingClientRect()`, which counts transforms, and it measures again when its size changes. The arrow and ios variants hold the rows 72px down with a transform while `onRefresh` runs, which is exactly when new rows arrive. The material ring floats over rows that never move. For the other end, `loadMode="pull"` on the Virtual List loads the next page when the user pulls past the last row and lets go.

## Which pull to refresh pitfalls show up in production?

Check these five before you ship, whether you use our component or your own React pull to refresh component:

- **A sticky or translucent header over the list.** The indicator hides under it. `indicatorOffset` moves its resting line down by the header's height.
- **A pull during the bounce at the top.** On iOS the scroll position goes negative while the list bounces past the top, so a strict `scrollTop === 0` check refuses pulls that start then. Treat anything at or below 0 as the top.
- **A fixed bar inside the pulled content.** The arrow and ios variants move the content with a transform, and a transformed element becomes the containing block of its `position: fixed` children, so a tab bar or floating button rendered inside rides down with the pull. Render fixed bars outside the component.
- **Nested pull to refresh areas,** such as a feed inside a tab of a page that refreshes. The inner one should win; the component makes the outer one ignore the gesture.
- **A refresh that never ends.** If `onRefresh` hangs, so does the spinner. Put the timeout on the request, for example `fetch(url, { signal: AbortSignal.timeout(10_000) })`, not on the indicator: the rejection then shows the error state and the indicator tells the truth.

For the rest of the mobile layer (bottom sheets, safe areas, dvh, tab bars), read the guide to [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components) or browse [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## What should you install next?

If your list already exists, wrap it. `npx wingo-ui@latest add pull-to-refresh` copies the component into your project with the Spinner and the hooks and helpers it imports (haptics, motion, scroll state, shortcuts), and installs `motion` and `lucide-react`. Pull To Refresh, List and Virtual List are part of [Wingo UI Pro](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`; the Spinner it uses is free.

## Components in this post

- [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) (Pro): Pull a list down past its top to reload it, with rubberband resistance, an arrow, iOS spokes or a Material ring, and a haptic tick. Install: `npx wingo-ui@latest add pull-to-refresh`
- [List](https://wingo-ui.com/components/list) (Pro): Rows on a page for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder. Install: `npx wingo-ui@latest add list`
- [Virtual List](https://wingo-ui.com/components/virtual-list) (Pro): Long and endless lists at 60fps: thousands of list rows virtualized, sticky group headers, infinite loading and scroll restoration. Install: `npx wingo-ui@latest add virtual-list`
- [Haptics](https://wingo-ui.com/components/haptics) (Pro): Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else. Install: `npx wingo-ui@latest add haptics`

## FAQ

### How do I disable the browser's pull to refresh in Chrome?

Set overscroll-behavior-y: contain on the element that scrolls, which is the html element when the window scrolls. Use none instead to remove the overscroll glow as well. Chrome has supported the property since version 63.

### Does overscroll-behavior stop pull to refresh in Safari on iOS?

Not reliably. Safari has supported the property since version 16, but WebKit bug 275947, open since June 2024, reports that it does not turn off Safari's own pull to refresh. Call preventDefault in a non-passive touchmove listener once the gesture is a pull.

### Why does preventDefault not work in onTouchMove in React?

Since React 17, onTouchStart, onTouchMove and onWheel are registered as passive listeners, and browsers ignore preventDefault in a passive listener. Attach the listener yourself with addEventListener and { passive: false } in an effect, on the scrolling element.

### Is a custom pull to refresh accessible?

Only with an alternative. WCAG 2.2 success criterion 2.5.7 Dragging Movements (level AA) exempts the browser's own drag to refresh but covers one you build, so add a refresh button or shortcut that runs the same reload and announce the result in a live region.

### Is the Wingo UI Pull To Refresh free?

No. It is a Pro component, like the List, the Virtual List and the haptics helper it pairs with; the Spinner it uses is free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add pull-to-refresh copies the component and everything it imports into your project.

---

Source: https://wingo-ui.com/blog/react-pull-to-refresh
