React Pull to Refresh in einer PWA ohne Browser-Ärger

Ein React Pull to Refresh sieht nach vierzig Zeilen Touch-Handlern aus, bis es in einem Handy-Browser läuft. Chrome auf Android und Safari auf iOS haben ihr eigenes Pull to Refresh, das die Seite neu lädt. React registriert onTouchMove als passiv, also bewirkt preventDefault() nichts. Und eine Liste, die dem Finger eins zu eins folgt, fühlt sich auf dem Handy falsch an. Dieser Guide löst jedes dieser Probleme für eine PWA, mit dem nackten Muster und mit Pull To Refresh von Wingo UI, das alles in eine Komponente packt.
Warum braucht eine PWA ihr eigenes Pull to Refresh?
Weil die eingebaute Variante des Browsers das ganze Dokument neu lädt und es sie in der installierten App womöglich gar nicht gibt. Ein Reload führt jedes Skript neu aus und verwirft den gesamten Zustand im Speicher: geladene Daten, ein offenes Sheet, die Scrollposition einer verschachtelten Liste. Ein Daten-Refresh lädt nur die Liste neu. Und eine Web-App, die auf dem iPhone zum Home-Bildschirm hinzugefügt wurde, öffnet im Standalone-Modus, ohne Safaris Toolbar und ohne sein Pull to Refresh (wie Nutzer sie dort ablegen, zeigt unser Tutorial zum React PWA Install Prompt). Discourse hat eine Theme-Komponente veröffentlicht, um es zurückzubringen; die Ankündigung vom Dezember 2024 (opens in a new tab) beginnt mit der Feststellung, dass fehlendes Pull to Refresh eine große Lücke in iOS-PWAs ist.
Fertige Pakete sind dünn gesät. Stand Oktober 2026 zeigt eine Suche nach pull to refresh react pwa noch immer @patlux/react-pull-to-refresh (opens in a new tab) (zuletzt veröffentlicht im November 2018) und react-js-pull-to-refresh (opens in a new tab) (zuletzt veröffentlicht im August 2022) weit oben, neben React-Native-Paketen, die nicht im Browser laufen. Android nennt die Geste Swipe to Refresh, React Swipe to Refresh ist also dasselbe unter anderem Namen.
Wie deaktivierst du das Pull to Refresh des Browsers?
Wer Pull to Refresh deaktivieren will, setzt overscroll-behavior-y: contain auf das Element, das scrollt: html, wenn das Fenster scrollt, sonst den Container. In Chrome erledigt diese eine Zeile das Thema overscroll-behavior Pull to Refresh komplett; dort gibt es die Eigenschaft seit Chrome 63 (opens in a new tab). contain behält den Overscroll-Glow, none entfernt ihn ebenfalls. Laut MDN (opens in a new tab) schaltet contain die native Navigation des Browsers ab, auch die vertikale Geste zum Aktualisieren.
Safari ist die Ausnahme. Es unterstützt die Eigenschaft seit Safari 16 (caniuse (opens in a new tab)), aber WebKit-Bug 275947 (opens in a new tab), gemeldet im Juni 2024 und bei unserer Prüfung im Oktober 2026 noch offen, berichtet, dass weder none noch contain Safaris Pull to Refresh auf iOS deaktivieren. Dort hilft JavaScript: Sobald ein Finger, der ganz oben aufsetzt, nach unten wandert, ruf preventDefault() auf ihrem touchmove auf. Dann scrollt die Seite nicht, federt nicht und startet unter dem Finger kein Safari-Refresh.
Genau hier kommt React in die Quere. Das Changelog von React 17 (opens in a new tab) hält fest, dass onTouchStart, onTouchMove und onWheel passiv bleiben. Ein passiver Listener kann das Event nicht abbrechen, also wird onTouchMove={(e) => e.preventDefault()} stillschweigend ignoriert. Häng den Listener selbst an, und zwar an den Scroller:
Ruf die Funktion in einem useEffect auf und gib ihr Cleanup zurück. Schreib dy in einen Motion Value oder eine Ref, nicht in State, sonst rendert jedes touchmove die Liste neu. Das ist ein Gerüst. Die Komponente von Wingo UI ergänzt, was fehlt: Sie wartet 8px Fingerweg ab, bevor sie entscheidet, ob eine Berührung ein Pull ist, verwirft Gesten, die eher seitwärts als nach unten gehen, bricht bei einem zweiten Finger ab (ein Pinch ist nie ein Pull) und startet nie in einem Eingabefeld, in [data-no-pull], in einem horizontalen Scroller oder in einer verschachtelten Liste, die selbst nicht ganz oben steht.
Wie viel Widerstand sollte ein Pull to Refresh haben?
Einen Widerstand, der wächst, je weiter der Finger geht. So bremst die Liste sanft ab, statt dem Finger eins zu eins bis zu einem harten Stopp zu folgen. react-simple-pull-to-refresh (opens in a new tab), das jüngste Web-Paket, das wir uns angesehen haben (1.3.4, 5. Januar 2026, MIT, React 16.10 bis 19), teilt den Fingerweg durch einen resistance-Prop (Standard 1) und begrenzt ihn bei maxPullDownDistance (Standard 95px): linear bis an eine Wand. Da es keine Abhängigkeiten hat, ist es eine solide Wahl für einen Prototyp.
Eine native Liste folgt einer Kurve. Der Helper rubberband in lib/motion von Wingo UI nutzt eine im Stil von iOS, (1 - 1 / (x * 0.55 / d + 1)) * d. Dabei ist x der Fingerweg nach den 8px Toleranz und d die Höhe des Scrollers (mindestens 480px, damit eine kurze Box so viel Widerstand bietet wie eine in Handygröße). Auf einem Scroller mit 480px und den Standardwerten bewegen 100px Fingerweg die Liste um 49px und 200px um 89px. Der Refresh wird bei 72px (threshold) nach etwa 154px scharf, und die Liste geht nie über maxPull (128px) hinaus. Der Pull-Weg steckt in einem Motion Value, also rendert ein touchmove deine Liste nie neu; React-State ändert sich nur, wenn die Phase wechselt (pulling, armed, refreshing, done).
Lässt du vor der Schwelle los, federt die Liste mit der Geschwindigkeit des Fingers zurück. Lässt du danach los, parkt sie an der Schwelle, während onRefresh läuft (mindestens 500ms, damit der Spinner nie nur aufblitzt). Danach zeigt sie für doneDuration (600ms) einen Haken, oder ein Warnsymbol, wenn das Promise fehlschlägt, bevor die Liste zurückgleitet. Bei reduzierter Bewegung springt die Liste zu jedem Ruhepunkt, statt zu federn.
Pfeil, iOS-Speichen oder Material-Ring: Welcher Indikator passt?
Richte dich nach der Plattform, die die meisten deiner Nutzer in der Hand halten, und entscheide, ob sich der Inhalt bewegen soll. Die drei Varianten der Komponente:
size setzt das Symbol auf 20, 24 oder 28px (den Material-Kreis auf 36, 40 oder 44px), tone färbt es (standardmäßig neutral), und showLabel ergänzt eine Zeile, die Pull to refresh, Release to refresh, Refreshing… und dann Updated anzeigt. Bei reduzierter Bewegung drehen sich die Speichen halb so schnell.
$ npx wingo-ui@latest add pull-to-refreshHier ein kompletter Posteingang, bei dem die iOS-Variante die List umschließt. scroll="self" macht die Komponente selbst zum Scroll-Container, mit overscroll-behavior-y: contain auf sich selbst. Mit dem Standard scroll="parent" beobachtet sie den Scroller, den du übergibst oder bereitstellst, sonst den nächsten scrollenden Vorfahren, sonst das Fenster, und setzt die Eigenschaft dort, solange sie gemountet und aktiv ist.
Der Indikator dreht sich, bis das Promise von onRefresh abgeschlossen ist; wird es abgelehnt, erscheint der Fehlerzustand. Übergib refreshing, wenn die App selbst einen Reload startet, und disabled, während jemand bearbeitet oder filtert: Schaltest du disabled mitten im Ziehen ein, federt die Liste ohne Refresh zurück.
Wie fügst du Pull to Refresh einen haptischen Tick hinzu?
Lös einen kurzen Tick aus, wenn der Pull die Schwelle überschreitet, also genau in dem Moment, ab dem Loslassen einen Refresh bedeutet; nie bei jedem Frame und nie beim Loslassen. Die Komponente ruft haptic("medium") aus dem Haptics-Helper auf, wenn der Pull scharf wird, und haptic("error"), wenn ein gezogener Refresh fehlschlägt. haptics={false} schaltet beides ab, und configureHaptics({ enabled }) ist der Schalter für die ganze App.
Betrachte den Tick als Bonus, denn die Unterstützung ist uneinheitlich. Stand Oktober 2026 führt caniuse (opens in a new tab) navigator.vibrate in Chrome für Android und Samsung Internet, in Firefox ab Version 129 entfernt und in Safari nie vorhanden. Auf dem iPhone klickt der Helper per Skript ein verstecktes <input type="checkbox" switch>, um den System-Tick abzuspielen. Der Switch kam mit Safari 17.4 (opens in a new tab), und die Ankündigung von WebKit erwähnt keine Haptik, also garantiert nichts, dass das so bleibt. ios-haptics (opens in a new tab) nutzt denselben Trick und schrieb in seiner README vom Juni 2026 (Version 3.1.1), dass es nur unter iOS 17.4 bis 26.4 funktioniert, weil Apple das in iOS 26.5 gepatcht hat. Die Version vom September 2026 (3.2.0) leitet stattdessen den echten Tap der Person als vertrauenswürdigen Klick an den Switch weiter, was darauf hindeutet, dass ein Klick per Skript keinen Tick mehr abspielt. Ein Pull erzeugt nie einen Tap, also rechne damit, dass der Tick unter iOS 26.5 und neuer stumm bleibt, und leg nie eine Information hinein, die der Indikator nicht zeigt.
Vibration braucht außerdem Sticky User Activation (opens in a new tab), und touchmove ist kein Aktivierungs-Event (touchend, das pointerup eines Fingers, mousedown und keydown sind es). Ist ein Pull das Erste, was jemand auf einer frisch geladenen Seite tut, bleibt der Tick stumm; haptic() gibt dann false zurück, statt Chrome einen blockierten Aufruf loggen zu lassen. Unser Guide zur Web Vibration API behandelt den Rest der Plattformlücke und eine Einstellung, mit der Nutzer Haptik abschalten.
Wie machst du ein eigenes Pull to Refresh barrierefrei?
Gib demselben Refresh einen Button, eine Taste und eine Ansage, denn Pull to Refresh ist eine Ziehbewegung. WCAG 2.2 Erfolgskriterium 2.5.7 (opens in a new tab) (Stufe AA) nimmt das Ziehen zum Aktualisieren des Browsers aus, weil der User Agent es bereitstellt. Sobald dein Inhalt ein eigenes umsetzt, gilt das Kriterium aber, also brauchst du eine Alternative, die mit einem einzelnen Zeiger ohne Ziehen auskommt. Der Swipe-to-Refresh-Guide von Android (opens in a new tab) verlangt dasselbe von nativen Apps: eine Refresh-Aktion im Overflow-Menü der App Bar, für Leute mit Tastatur oder D-Pad.
Die Komponente bietet für jede Eingabeart einen Weg. setApi gibt dir { refresh() }, das genau das ausführt, was ein Pull ausführt (Indikator, Promise, Done-Zustand). So können Header-Button und Pull nie auseinanderlaufen; ein Aufruf während eines laufenden Refresh gibt das laufende Promise zurück. shortcut bindet eine Taste wie shift+r. Eine Live-Region mit polite sagt Refreshing… an, danach Updated oder den Fehler, und das Root-Element trägt so lange aria-busy. Auf dem Laptop löst ein weiterer Scroll nach oben ganz am Anfang mit Trackpad oder Mausrad einen Pull aus (wheel, standardmäßig an; Momentum, das einen Scroll bis nach oben trägt, tut das nie), und pullWithMouse erlaubt das Ziehen mit der Maus, für Kiosk-Geräte. Der Button im Header des Posteingang-Beispiels nutzt size="icon" des Buttons, also 40px mit Maus und 44px auf Touchscreens, die Größe, die der Guide zur minimalen Touch-Target-Größe empfiehlt.
$ npx wingo-ui@latest add pull-to-refreshWie nutzt du Pull to Refresh mit einer langen virtualisierten Liste?
Lass genau ein Element scrollen und teile es, damit beide Komponenten dieselbe Scrollposition beobachten. Die Virtual List mit scroll="window" folgt dem nächsten ScrollContainerProvider, und Pull To Refresh mit dem Standard scroll="parent" ebenfalls:
Nimm hier die Material-Variante. Die Virtual List misst mit getBoundingClientRect(), wo sie im Scroller beginnt, und das zählt Transforms mit; sie misst erneut, wenn sich ihre Größe ändert. Die Varianten arrow und ios halten die Zeilen per Transform 72px weiter unten, während onRefresh läuft, und genau dann kommen neue Zeilen an. Der Material-Ring schwebt über Zeilen, die sich nie bewegen. Für das andere Ende lädt loadMode="pull" auf der Virtual List die nächste Seite, wenn jemand über die letzte Zeile hinaus zieht und loslässt.
Welche Pull-to-Refresh-Fallen tauchen in Produktion auf?
Prüf diese fünf vor dem Release, egal ob du unsere Komponente nutzt oder deine eigene React-Pull-to-Refresh-Komponente:
- Ein sticky oder halbtransparenter Header über der Liste. Der Indikator verschwindet darunter.
indicatorOffsetschiebt seine Ruhelinie um die Höhe des Headers nach unten. - Ein Pull während des Bounce ganz oben. Auf iOS wird die Scrollposition negativ, während die Liste über den Anfang hinaus federt. Eine strikte Prüfung auf
scrollTop === 0lehnt Pulls ab, die genau dann beginnen. Behandle alles bei oder unter 0 als Anfang. - Eine fixierte Leiste im gezogenen Inhalt. Die Varianten arrow und ios bewegen den Inhalt per Transform, und ein transformiertes Element wird zum Containing Block seiner Kinder mit
position: fixed. Eine Tab Bar oder ein schwebender Button darin fährt also beim Ziehen mit nach unten. Rendere fixierte Leisten außerhalb der Komponente. - Verschachtelte Pull-to-Refresh-Bereiche, etwa ein Feed in einem Tab einer Seite, die sich ebenfalls aktualisiert. Der innere sollte gewinnen; die Komponente sorgt dafür, dass der äußere die Geste ignoriert.
- Ein Refresh, der nie endet. Hängt
onRefresh, hängt auch der Spinner. Setz das Timeout auf den Request, zum Beispielfetch(url, { signal: AbortSignal.timeout(10_000) }), nicht auf den Indikator: Das abgelehnte Promise zeigt dann den Fehlerzustand, und der Indikator sagt die Wahrheit.
Für den Rest der mobilen Ebene (Bottom Sheets, Safe Areas, dvh, Tab Bars) lies den Guide zu Mobile-first React-Komponenten oder stöber in Mobile UI in React.
Was installierst du als Nächstes?
Wenn deine Liste schon steht, umschließ sie einfach. npx wingo-ui@latest add pull-to-refresh kopiert die Komponente mit dem Spinner und den Hooks und Helpern, die sie importiert (Haptics, Motion, Scroll-State, Shortcuts), in dein Projekt und installiert motion und lucide-react. Pull To Refresh, List und Virtual List gehören zu Wingo UI Pro, also melde dich zuerst mit npx wingo-ui@latest login an; der Spinner, den die Komponente nutzt, ist kostenlos.
Häufige Fragen
Wie kann ich Pull to Refresh in Chrome deaktivieren?
Setz overscroll-behavior-y: contain auf das Element, das scrollt. Scrollt das Fenster, ist das html. Mit none verschwindet zusätzlich der Overscroll-Glow. Chrome unterstützt die Eigenschaft seit Version 63.
Stoppt overscroll-behavior Pull to Refresh in Safari auf iOS?
Nicht zuverlässig. Safari unterstützt die Eigenschaft seit Version 16, aber laut WebKit-Bug 275947, offen seit Juni 2024, schaltet sie Safaris eigenes Pull to Refresh nicht ab. Ruf preventDefault in einem nicht passiven touchmove-Listener auf, sobald die Geste als Pull erkannt ist.
Warum funktioniert preventDefault in onTouchMove in React nicht?
Seit React 17 registriert React onTouchStart, onTouchMove und onWheel als passive Listener, und Browser ignorieren preventDefault in einem passiven Listener. Häng den Listener selbst mit addEventListener und { passive: false } in einem Effect an das scrollende Element.
Ist ein eigenes Pull to Refresh barrierefrei?
Nur mit einer Alternative. WCAG 2.2 Erfolgskriterium 2.5.7 Dragging Movements (Stufe AA) nimmt das Ziehen zum Aktualisieren des Browsers aus, gilt aber für eines, das du selbst baust. Ergänze also einen Refresh-Button oder ein Tastenkürzel, das denselben Reload ausführt, und sag das Ergebnis in einer Live-Region an.
Ist Pull To Refresh von Wingo UI kostenlos?
Nein. Es ist eine Pro-Komponente, genau wie die List, die Virtual List und der Haptics-Helper, mit denen sie zusammenarbeitet; der Spinner, den sie nutzt, ist kostenlos. Mit einem Pro-Account, angemeldet über npx wingo-ui@latest login, kopiert npx wingo-ui@latest add pull-to-refresh die Komponente und alles, was sie importiert, in dein Projekt.
- Mobile UI
- Pull to Refresh
- PWA
- React
- Gesten


