DEV Community

Cover image for React Pull to Refresh in a PWA Without Fighting the Browser
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

React Pull to Refresh in a PWA Without Fighting the Browser

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 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 covers how people get it there). Discourse shipped a theme component to add one back; its December 2024 announcement 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 (last published November 2018) and 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; contain keeps the overscroll glow and none removes it too. Per MDN, 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), but WebKit bug 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 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:

// 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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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, 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. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Pull To Refresh docs. Install: npx wingo-ui@latest add pull-to-refresh

Here is a full inbox screen with the iOS variant around the 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.

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 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 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, and WebKit's announcement does not mention a haptic, so nothing promises it will last. 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, 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 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 (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 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 recommends.

Live demo: Pull To Refresh. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Pull To Refresh docs. Install: 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 with scroll="window" follows the nearest ScrollContainerProvider, and Pull To Refresh with its default scroll="parent" does too:

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 or browse Mobile UI in React.

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, so sign in first with npx wingo-ui@latest login; the Spinner it uses is free.

Components in this post

  • 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 (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 (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 (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.


This article was originally published on the Wingo UI blog: React Pull to Refresh in a PWA Without Fighting the Browser. The original has the live, interactive demos and is kept up to date.

Top comments (1)

Collapse
 
lycore profile image
Lycore Development •

The passive touchmove point is the one that trips most people, and attaching the listener manually with { passive: false } is the right fix. One edge case worth testing is a pull gesture that starts inside a nested scrollable element: you only want to take over when that inner scroller is at scrollTop 0, otherwise users can't scroll back up a modal list. I'd also cancel the gesture on touchcancel and when a second finger lands, since iOS can fire those mid-drag and leave the indicator stuck. Did you find a reliable way to detect the standalone iOS case, or do you just apply the same JS path everywhere?