WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Pull to Refresh in a PWA Without Fighting the Browser
  1. Blog
  2. Mobile UI in React
  3. React Pull to Refresh in a PWA Without Fighting the Browser
Mobile UI in React
Mobile UI in React

React Pull to Refresh in a PWA Without Fighting the Browser

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. Why does a PWA need its own pull to refresh?
  2. How do you stop the browser's own pull to refresh?
  3. How much resistance should a pull to refresh have?
  4. Arrow, iOS spokes or a Material ring: which indicator should you use?
  5. How do you add a haptic tick to a pull to refresh?
  6. How do you make a custom pull to refresh accessible?
  7. How do you use pull to refresh with a long virtualized list?
  8. Which pull to refresh pitfalls show up in production?
  9. What should you install next?
  10. FAQ
    1. How do I disable the browser's pull to refresh in Chrome?
    2. Does overscroll-behavior stop pull to refresh in Safari on iOS?
    3. Why does preventDefault not work in onTouchMove in React?
    4. Is a custom pull to refresh accessible?
    5. Is the Wingo UI Pull To Refresh free?

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.

Published Oct 9, 2026

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:

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 (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:

variantThe listWhile pullingWhile refreshingGood fit
arrow (default)moves downan arrow that turns at the thresholda spinnerneutral apps on both platforms
iosmoves down12 spokes that draw one by onethe spokes spin in 12 steps, like UIRefreshControliPhone-first apps
materialstays puta floating circle whose ring fillsa spinning ring in the circleAndroid-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.

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.
$ npx wingo-ui@latest add pull-to-refresh
ProPull To Refresh docs

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.

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

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.
$ npx wingo-ui@latest add pull-to-refresh
ProPull To Refresh docs

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:

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

    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.

    Pro
  • List

    Rows on a page for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder.

    Pro
  • Virtual List

    Long and endless lists at 60fps: thousands of list rows virtualized, sticky group headers, infinite loading and scroll restoration.

    Pro
  • Haptics

    Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else.

    Pro

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Mobile UI in React

Web Vibration API: Haptic Feedback on Android and iPhone

The Web Vibration API in 2026: navigator.vibrate on Android, the iPhone switch tick that iOS 26.5 limits to real taps, and a React helper that fails quietly.

SRSerban Rusu·Oct 9, 2026·11 min read
Comparisons and alternatives

Best React Component Library for Mobile in 2026: 9 Compared

The best React component library for mobile web apps in 2026: 9 libraries checked at 390px for bottom sheets, touch targets, safe areas and gestures.

SRSerban Rusu·Oct 9, 2026·12 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI