Problem
The pull-to-refresh implementation has haptic feedback coded to fire when the user crosses the pull threshold, but haptics do not trigger on iOS Safari despite multiple implementation approaches.
Button haptics in the same app work correctly using the same triggerHaptic() function from src/lib/haptics.ts.
Technical Details
Haptic Library
- Using
web-haptics library (https://haptics.lochie.me/)
- On iOS Safari, it uses a "hidden switch hack" - programmatically clicking a hidden
<input type="checkbox"> element which triggers the Taptic Engine
- This works for button
onClick handlers but NOT for touch gesture handlers
Root Cause (Best Understanding)
Per Codex GPT-5.4 analysis:
"On iOS Safari it synthesizes haptics by programmatically clicking a hidden switch. That means the call has to stay inside Safari's trusted tap/click activation path; a custom drag gesture is easier to fall outside of that than a button onClick."
The web-haptics library's click-a-hidden-switch approach only works inside Safari's "trusted gesture activation path." Custom drag gestures (touchmove) appear to fall outside this trust chain, even when:
- Using
passive: false on the event listener
- Calling
e.preventDefault() to stay in an active gesture context
- Firing the haptic synchronously during the touch event
Approaches Attempted (All Failed)
-
Haptic in touchmove with passive: false + preventDefault()
- Expected: Should maintain trusted gesture context
- Result: No haptic
-
Haptic in touchmove before preventDefault()
- Expected: Fire haptic before canceling default behavior
- Result: No haptic
-
Using setTimeout(() => triggerHaptic(), 0)
- Expected: Decouple from touch event context
- Result: No haptic
-
Using navigator.vibrate() directly
- Result: Not supported on iOS Safari at all
-
Haptic on touchend instead of touchmove
- Expected: touchend is similar to click context
- Result: No haptic
-
All passive handlers with CSS touch-action: none
- Expected: Avoid preventDefault issues entirely
- Result: No haptic
-
Using pulltorefreshjs library
- Result: Library handles its own UI (ugly), haptics still don't work
Current Implementation
The PTR is in src/hooks/usePullToRefresh.ts and works correctly for:
- Visual feedback (content translates down with rubber-band physics)
- Nested scroll detection
- Direction lock (ignores horizontal swipes)
- Calling the refresh callback
Only haptic feedback is broken.
Relevant Files
src/hooks/usePullToRefresh.ts - PTR hook with haptic calls
src/lib/haptics.ts - Haptic module using web-haptics
src/components/Button.tsx - Working haptics (for reference)
src/components/Content.tsx - Uses the PTR hook
Possible Solutions to Investigate
-
Different haptic library - Is there one that uses a different iOS Safari hack?
-
Native bridge - If this becomes a Capacitor app, use native haptics API
-
Accept limitation - Only fire haptics on refresh completion (after the async callback), not on threshold crossing
-
Apple WebKit bug/feature request - The Taptic Engine API restriction to "trusted" gestures may be intentional for battery/UX reasons
Environment
- iOS Safari (iPhone)
web-haptics v0.1.x
- React 18
- Vite
Related
Problem
The pull-to-refresh implementation has haptic feedback coded to fire when the user crosses the pull threshold, but haptics do not trigger on iOS Safari despite multiple implementation approaches.
Button haptics in the same app work correctly using the same
triggerHaptic()function fromsrc/lib/haptics.ts.Technical Details
Haptic Library
web-hapticslibrary (https://haptics.lochie.me/)<input type="checkbox">element which triggers the Taptic EngineonClickhandlers but NOT for touch gesture handlersRoot Cause (Best Understanding)
Per Codex GPT-5.4 analysis:
The
web-hapticslibrary's click-a-hidden-switch approach only works inside Safari's "trusted gesture activation path." Custom drag gestures (touchmove) appear to fall outside this trust chain, even when:passive: falseon the event listenere.preventDefault()to stay in an active gesture contextApproaches Attempted (All Failed)
Haptic in touchmove with
passive: false+preventDefault()Haptic in touchmove before
preventDefault()Using
setTimeout(() => triggerHaptic(), 0)Using
navigator.vibrate()directlyHaptic on touchend instead of touchmove
All passive handlers with CSS
touch-action: noneUsing
pulltorefreshjslibraryCurrent Implementation
The PTR is in
src/hooks/usePullToRefresh.tsand works correctly for:Only haptic feedback is broken.
Relevant Files
src/hooks/usePullToRefresh.ts- PTR hook with haptic callssrc/lib/haptics.ts- Haptic module using web-hapticssrc/components/Button.tsx- Working haptics (for reference)src/components/Content.tsx- Uses the PTR hookPossible Solutions to Investigate
Different haptic library - Is there one that uses a different iOS Safari hack?
Native bridge - If this becomes a Capacitor app, use native haptics API
Accept limitation - Only fire haptics on refresh completion (after the async callback), not on threshold crossing
Apple WebKit bug/feature request - The Taptic Engine API restriction to "trusted" gestures may be intentional for battery/UX reasons
Environment
web-hapticsv0.1.xRelated