Skip to content

Observation: Haptic feedback timing varies by triggering element type on iOS Safari #9

@phoozle

Description

@phoozle

Summary

While integrating web-haptics (v0.0.6) into a Rails application, we observed that the perceived timing of haptic feedback varies significantly depending on which HTML element type triggers the trigger() call. Specifically, <button> elements produce near-instant haptic feedback, while <a>, <div>, and <span> elements produce noticeably delayed feedback — even when the JavaScript execution timing is identical.

Environment

  • Device: iPhone 17 Pro Max
  • OS: iOS 18+
  • Browser: Safari (mobile)
  • Library version: web-haptics 0.0.6 (via jspm CDN)
  • Framework: Stimulus + Turbo (Hotwire), though we isolated the issue outside of Turbo — see below

What we observed

We built a test page with identical trigger() calls bound to click events on different element types. Each element has the same styling, same event binding, same haptic pattern ("medium"). The only variable is the element tag.

Results

Element Haptic timing
<button> Instant — felt simultaneously with the tap
<button type="button"> Instant
<a href="#"> + preventDefault Noticeably delayed
<a href="/path"> + preventDefault Noticeably delayed
<a> (no href) Noticeably delayed
<div> Noticeably delayed
<span> Noticeably delayed

"Noticeably delayed" means the haptic is felt approximately 100-200ms after the tap — enough to feel disconnected from the gesture. The <button> elements feel perfectly synchronised.

All haptic patterns (light, medium, heavy, soft, rigid, selection, success, warning, error) exhibit this same element-dependent timing difference.

What we ruled out

We spent significant time isolating the cause:

  1. Not a JavaScript execution delay. We added performance.now() logging throughout the call chain. From click handler entry to trigger() completion was consistently ~5ms for all element types.

  2. Not Turbo/SPA related. We tested with preventDefault() only (no navigation), with Turbo Drive disabled, and with setTimeout delays up to 2000ms before navigating. The haptic delay was identical in all cases.

  3. Not a library code path issue. The same WebHaptics instance and same trigger() call produces different perceived timing depending solely on the element type that received the click.

Our workaround

We changed our navigation items from <a> tags to <button> tags and use Turbo.visit(href) for navigation. This produces instant haptic feedback.

<!-- Before (delayed haptic) -->
<a href="/path" data-action="click->nav#tap">...</a>

<!-- After (instant haptic) -->
<button data-action="click->nav#tap" data-href="/path">...</button>

Speculation

This may be related to how Safari handles user activation context for the <input type="checkbox" switch> + <label> mechanism that the library uses. Safari might grant different levels of activation trust to click events originating from <button> elements vs other element types. We haven't confirmed this at the engine level — just reporting what we observed.

Test page code

For reference, here's the minimal Stimulus controller and markup we used to isolate this:

Controller:

import { Controller } from "@hotwired/stimulus"
import { haptic } from "haptics"

export default class extends Controller {
  fire() {
    haptic("medium")
  }

  firePrevent(event) {
    event.preventDefault()
    haptic("medium")
  }

  firePattern(event) {
    haptic(event.params.pattern)
  }
}

Markup (simplified):

<div data-controller="haptic-test">
  <button data-action="click->haptic-test#fire">
    button — haptic("medium")
  </button>

  <a href="#" data-action="click->haptic-test#firePrevent">
    a href="#" + preventDefault — haptic("medium")
  </a>

  <div data-action="click->haptic-test#fire">
    div — haptic("medium")
  </div>

  <span data-action="click->haptic-test#fire">
    span — haptic("medium")
  </span>
</div>

Notes

  • We only had one iOS device to test with (iPhone 17 Pro Max), so we can't confirm whether this varies across devices or iOS versions
  • The library's isSupported returns false on iOS Safari (no navigator.vibrate), so it correctly falls through to the DOM-based <input switch> mechanism
  • This isn't a bug report per se — just documenting an observation that might be useful for the project and other users

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions