Under React Native Web, a PanResponder swipe row works on phones but drops gestures on desktop; how are responder events produced in a browser?
answer
- listeners on document, not the element
- a mouse becomes one emulated touch
- primary button only
- context menu, blur, dragstart end it
- stopPropagation stops negotiation only
basics
~20 sReact Native Web reimplements the responder system with document-level mouse and touch listeners, treating a mouse as one emulated touch. Context menus, window blur, native drags, text selection and ancestor scrolls terminate the responder, which is where desktop gestures usually die.
solid answer
~40 sReact Native Web does not get responder events from a native gesture system; it rebuilds React Native's responder negotiation in JavaScript. On first use it attaches listeners on `document` for `mousedown`, `mousemove`, `mouseup`, `touchstart`, `touchmove`, `touchend`, `touchcancel`, `dragstart`, `contextmenu`, `select` and `selectionchange`, plus capture-phase `scroll` and `blur`. A mouse is reported as a single touch, only the primary button starts a gesture, and emulated mouse events after a touch are ignored. The responder is **terminated** by a context menu, a window blur, a `dragstart` (an image or link being dragged), text selection, or a scroll of an ancestor; only the context-menu, scroll and selection cases consult `onResponderTerminationRequest`. `stopPropagation` in a negotiation handler stops negotiation, not the DOM event. Fixes: `userSelect: 'none'` on the row, no draggable content inside, and `touchAction` on touch screens.
code
tsx · 25 linesimport { useRef, type ReactNode } from 'react';
import { Animated, PanResponder, Platform } from 'react-native';
// web only: no text selection mid-drag, browser keeps vertical scrolling only
const webRow = Platform.OS === 'web' ? ({ userSelect: 'none', touchAction: 'pan-y' } as object) : null;
export function SwipeRow({ children }: { children: ReactNode }) {
const dx = useRef(new Animated.Value(0)).current;
const responder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 8,
onPanResponderMove: Animated.event([null, { dx }], { useNativeDriver: false }),
onPanResponderTerminationRequest: () => false,
onPanResponderRelease: () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: false }).start();
},
})
).current;
return (
<Animated.View style={[webRow, { transform: [{ translateX: dx }] }]} {...responder.panHandlers}>
{children}
</Animated.View>
);
}go deeper
Recall that PanResponder and responder props do run on the web, driven by mouse and touch events, and that a mouse counts as a single touch.
Explain the document-level listeners, the negotiation walk, and the list of browser events that end a gesture on the web.
Diagnose dropped gestures from the termination table: text selection, native drags, ancestor scrolls and context menus, and fix them with userSelect, touchAction and termination requests.
Decide when a shared gesture should stay on PanResponder and when a gesture library with a real web implementation is worth the extra dependency.
## The symptom A list row uses `PanResponder` to swipe open actions. On iOS and Android it is solid. In a desktop browser, running through **React Native Web**, the swipe sometimes stops halfway, snaps back when the cursor crosses the row's text, or never starts when the drag begins on the row's thumbnail. To diagnose it you need to know that the web has no native responder system: React Native Web builds one out of DOM events. ## How responder events are produced `PanResponder` only returns responder props (`onStartShouldSetResponder`, `onResponderMove` and friends) that you spread on a `View`. On the web, every `View` and `Text` passes those props to an internal hook that registers the element with a JavaScript responder system: - On first use it attaches listeners **on `document`**, not on your element: `mousedown`, `mousemove`, `mouseup`, `dragstart`, `touchstart`, `touchmove`, `touchend`, `touchcancel`, `contextmenu`, `select` and `selectionchange` in the bubble phase, `blur` and `scroll` in the capture phase, and `blur` on `window`. - It walks the DOM from the event target to run the capture and bubble **negotiation** (`on*ShouldSetResponderCapture`, then `on*ShouldSetResponder`) the same way native does. - A **mouse is reported as a single touch**: `touches` holds one entry, with `pageX`, `pageY`, `locationX` and `locationY`. Only the **primary button** starts a gesture, and `mousemove` without a preceding `mousedown` is ignored. - After a real `touchstart`, the browser's **emulated mouse events are ignored**, so a tap is not counted twice. It uses mouse and touch events rather than Pointer Events, which the source attributes to older Safari support. ## What terminates the responder | Browser event | Effect on the current responder | |---|---| | `contextmenu` (right-click, long touch) | terminates, unless `onResponderTerminationRequest` returns `false` | | `scroll` of an ancestor, or any scroll with no pointer down | terminates, unless the termination request returns `false` | | `selectionchange` with a real text selection | terminates, unless the termination request returns `false` | | `blur` of the window, or of the responder or an ancestor | terminates | | `dragstart` (native drag of an image, link or selection) | cancels the gesture | | `touchcancel` | cancels the gesture | Only the first three consult `onResponderTerminationRequest`; the rest end the gesture regardless. ## Reading the symptom against the table 1. **Stops when crossing text**: the mouse drag selects the row's text, `selectionchange` fires, the responder is terminated. Set `userSelect: 'none'` on the row, or return `false` from `onResponderTerminationRequest`. 2. **Never starts on the thumbnail**: a native drag begins on the image or a link, `dragstart` cancels. React Native Web's `Image` already sets `draggable={false}` on its hidden `img`, but a raw anchor from an `href` or custom HTML inside the row can still be dragged. 3. **Dies on a touch laptop or tablet**: the browser starts a native scroll, the ancestor `scroll` terminates the responder. A `touchAction` style such as `'pan-y'` on the row lets the browser scroll vertically and leaves horizontal movement to your handler. 4. **Right-click mid-swipe**: `contextmenu` ends it; decide whether to refuse termination. ## Two behaviours that surprise native developers - **`stopPropagation` does not stop the DOM event.** Calling it inside a negotiation handler stops further responder negotiation only; the native event has already reached `document`. - **`onPress` is separate.** `Pressable` on the web fires `onPress` from the native `click` event, not from responder release, so a swipe row that contains pressables can see a click after a short drag. ## A debugging checklist 1. Log `onResponderTerminate` with the event type; the table above tells you which browser behaviour ended the gesture. 2. Check whether the drag starts on text, an image or an anchor, the three most common sources of selection and native-drag terminations. 3. Try the gesture with a trackpad, a mouse and a touch screen; each produces different browser events. 4. Confirm the element is actually registered: responder props on a component that does not forward them to a host `View` never reach the system. ## When to stop hand-rolling `PanResponder` works on the web, but it runs every move in JavaScript and inherits all of the termination rules above. For complex gestures, a dedicated gesture library with a web implementation is usually the better tool; that is a separate choice from understanding why the responder dropped the gesture.
- Under React Native Web, why does calling stopPropagation in onStartShouldSetResponder not stop a parent's DOM click handler?Because React Native Web's responder system listens on `document`: by the time negotiation runs, the native event has already bubbled all the way up. `stopPropagation` on the responder event only stops further responder negotiation. To stop a DOM handler you must handle the DOM event itself, for example with `onClick` on the element.
- Under React Native Web, which terminations can onResponderTerminationRequest refuse?Only those caused by `contextmenu`, `scroll` and `selectionchange`. A window blur, a blur of the responder or an ancestor, a native `dragstart` or a `touchcancel` end the gesture regardless of what the handler returns, so they must be prevented at the source (no draggable content, no focus changes mid-gesture).
saying these in an interview costs you the question
- React Native Web forwards native touch gestures from the operating system.
- Returning false from onResponderTerminationRequest prevents every kind of termination.
- Mouse events are ignored by PanResponder on the web.
- stopPropagation in a negotiation handler also stops the DOM event.
- Responder listeners are attached to each View's own element.