React Pull to Refresh in a PWA Without Fighting the Browser
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 in a new tab) 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 (opens in a new tab) (last published November 2018) and react-js-pull-to-refresh (opens in a new tab) (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 (opens in a new tab); contain keeps the overscroll glow and none removes it too. Per MDN (opens in a new tab), 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 (opens in a new tab)), but WebKit bug 275947 (opens in a new tab), 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 (opens in a new tab) 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:
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 (opens in a new tab), 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:
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.
$ npx wingo-ui@latest add pull-to-refreshHere 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.
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 (opens in a new tab) 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 (opens in a new tab), and WebKit's announcement does not mention a haptic, so nothing promises it will last. ios-haptics (opens in a new tab), 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 (opens in a new tab), 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 (opens in a new tab) (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 (opens in a new tab) 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.
$ npx wingo-ui@latest add pull-to-refreshHow 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 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.
indicatorOffsetmoves 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 === 0check 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: fixedchildren, 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
onRefreshhangs, so does the spinner. Put the timeout on the request, for examplefetch(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.
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.
- Mobile UI
- Pull to refresh
- PWA
- React
- Gestures