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

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/web-vibration-api-haptic-feedback

## TL;DR

As of October 2026 the Web Vibration API (navigator.vibrate) works only in Chromium browsers on Android, only after the user has tapped the page, and with no control over intensity; Safari on iPhone has never shipped it. The one haptic a page can trigger on an iPhone is the system tick of a native HTML switch, and since iOS 26.5 WebKit plays it only when the user's own tap toggles the switch, never for a click from JavaScript. Put navigator.vibrate behind one helper that returns false instead of throwing, lay a label for a hidden switch over buttons that should tick on iPhone, and never let a tick be the only feedback.

You want a tap to tick under the thumb the way it does in a native app, so you reach for the web vibration API. On an Android phone `navigator.vibrate(10)` works on the first try. On an iPhone nothing happens, on a laptop the call returns `true` and nothing happens, and the iPhone workaround you found on GitHub stopped working in iOS 26.5. This tutorial maps what a page can really trigger in October 2026, builds a helper that stays silent wherever it cannot play, and wires haptics into a switch, a button and a wheel picker in React.

## What can the Web Vibration API do in 2026?

It can switch the vibration motor on and off for durations you give in milliseconds, in Chromium browsers on Android, after the user has tapped the page. That is the whole API: one method, navigator.vibrate, that takes a number or an array of alternating vibrate and pause times. There is no intensity, no waveform and no way to ask whether the device has a motor at all. Software Mansion's [August 2026 overview of web haptics](https://swmansion.com/blog/haptic-feedback-on-the-web-why-the-web-deliberately-refuses-to-be-as-tactile-as-native-apps) sums up the support in one line: "the vibration API is effectively limited to Chromium-based browsers."

Here is where a tick can reach a finger, as of October 2026 ([caniuse](https://caniuse.com/vibration) for support, our own test in desktop Chromium for the last row):

| Where the page runs | `navigator.vibrate` | What the user feels |
| --- | --- | --- |
| Chrome or Samsung Internet on Android | supported | the pattern you pass, once the page has seen a tap |
| Firefox | removed in version 129, not supported on Android | nothing |
| Safari on iPhone, and home screen web apps | never shipped | the system tick of a native switch, only when the user's own tap toggles it |
| Chrome on a desktop | present, returns `true` | nothing, there is no motor |

The last row is the trap. Feature detection with `"vibrate" in navigator` says yes in desktop Chrome, so it tells you nothing about hardware. The boolean that `navigator.vibrate()` returns only says the browser accepted the pattern; it says nothing about whether anyone felt it.

## How does navigator.vibrate behave on Android?

It plays the pattern you pass and replaces any pattern already running, within limits you can read in Chromium's [vibration controller](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/vibration/vibration_controller.cc):

```ts
navigator.vibrate(8); // one 8ms pulse: a light tick
navigator.vibrate([10, 60, 16]); // pulse, pause, pulse: a success pattern
navigator.vibrate(0); // cancels whatever is running (so does [])
```

- **It needs a tap first.** [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/vibrate) lists sticky user activation as a requirement. Until the page has seen a tap, a click or a key press, Chrome returns `false` and logs "Blocked call to navigator.vibrate because user hasn't tapped on the frame or any embedded frame yet". Once granted, the activation lasts until the page navigates, so a tick from a scroll handler or after an `await` works later on.
- **It is silent in the background.** The call returns `false` while the page is hidden.
- **Long patterns are trimmed.** Chromium caps each entry at 10,000ms, keeps the first 99 entries and drops a trailing pause.
- **The phone can overrule you.** MDN notes that some devices do not vibrate in silent or Do Not Disturb mode.

Keep the pulses short; the helper below uses 4 to 22ms per pulse. The 200ms buzz in many examples (MDN's first one included) reads as a notification, which is the wrong message for a toggle.

## Does the Web Vibration API work on iOS?

No. Safari has never implemented navigator.vibrate, and caniuse lists no version of Safari on iOS with it, up to 27.2. Look up web vibration API iOS support and you land on one workaround: Safari 17.4 added a native switch control, `<input type="checkbox" switch>` ([WebKit's announcement](https://webkit.org/blog/15054/an-html-switch-control/)), and toggling it plays the system haptic tick.

Until iOS 26.5, libraries such as ios-haptics played that tick on demand by calling `.click()` on a hidden label wired to the switch. That no longer works. The [ios-haptics README of April 2026](https://www.npmjs.com/package/ios-haptics/v/0.1.5) says the trick "only works on ios 17.4 to 26.4, as apple patched it in ios 26.5". The [WebKit fix](https://commits.webkit.org/313638@main), merged into the main branch on May 21, 2026, closes that gap: WebKit already required user activation for the haptic, and now it also requires a trusted event, which a click forwarded from `label.click()` no longer is.

What still works is a real tap that lands on a label for the switch. The tap produces a trusted click, the label forwards it to the switch, and because the original click was trusted, the WebKit change still lets the switch tick. The [ios-haptics source](https://github.com/tijnjh/ios-haptics) moved to exactly that on September 22, 2026: it lays a transparent label over your element. Here is the same idea as a React 19 component:

```tsx
// components/ios-tap-tick.tsx
"use client";

// iOS plays the switch haptic only for a trusted click, so the user's own tap has to land on a label for the switch.
// put it inside a positioned button; it covers the button and the click still bubbles to the button's onClick
export function IOSTapTick() {
  return (
    <label
      aria-hidden="true"
      data-slot="ios-tap-tick"
      className="absolute inset-0 touch-manipulation [-webkit-tap-highlight-color:transparent]"
    >
      <input
        type="checkbox"
        // react 19 passes the unknown attribute through; the spread keeps TypeScript quiet
        {...{ switch: "" }}
        tabIndex={-1}
        // the label, not the switch, sits under the finger, so a swipe that starts here still scrolls the page
        className="invisible absolute m-0 size-px"
        // the label forwards the click to the switch: stop that second click before it reaches the button
        onClick={(event) => event.stopPropagation()}
      />
    </label>
  );
}
```

Use it in buttons, not links: in our Chromium test a label inside an `<a>` ran the link's `onClick` but took over the default action, so the link never navigated. Leave the input without a `name`, so it never ends up in a form submission. Outside iOS a tap on it only toggles a hidden checkbox nobody sees, so it can render on every platform and never causes a hydration mismatch.

On an iPhone you get one fixed tick per tap, from the tap itself: no patterns, nothing after an `await`, nothing during a drag, and nothing for a wheel spinning past rows, because a scroll never produces a click.

## How do you call navigator.vibrate safely where it is unsupported?

Check for the method itself, check activation, catch everything, and return a boolean. Here is the whole helper without dependencies:

```ts
// lib/vibrate.ts
export type HapticKind = "selection" | "light" | "medium" | "heavy" | "success" | "warning" | "error";

const PATTERNS: Record<HapticKind, number | number[]> = {
  selection: 4,
  light: 8,
  medium: 14,
  heavy: 22,
  success: [10, 60, 16],
  warning: [16, 70, 16],
  error: [22, 50, 22, 50, 22],
};

let enabled = true;

export function setHapticsEnabled(value: boolean) {
  enabled = value;
}

export function vibrate(kind: HapticKind = "light"): boolean {
  if (!enabled) return false;
  // node 21+ has a global navigator too, so test for the method, not for navigator
  if (typeof navigator === "undefined" || typeof navigator.vibrate !== "function") return false;
  // chrome blocks the call (and logs a warning) until the page has seen a tap
  if (navigator.userActivation && !navigator.userActivation.hasBeenActive) return false;
  try {
    return navigator.vibrate(PATTERNS[kind]);
  } catch {
    return false; // feedback is never worth an exception
  }
}
```

The Node check matters. Node 21 and later define a global `navigator` with no `vibrate`, so a helper that only checks `typeof navigator` gets past its guard on the server and calls a method that is not there.

Wingo UI's [haptics helper](https://wingo-ui.com/components/haptics) is the same idea plus an iPhone fallback and a `force` option that plays a tick even when haptics are off. `haptic(kind)` returns whether a haptic was sent, `configureHaptics({ enabled, iosFallback })` sets the app-wide options, and `supportsHaptics()` tells you whether the browser exposes a haptic path at all (call it in an effect: it is `false` on the server, and `true` in desktop Chrome for the reason above).

> Live demo (Haptics): On an Android phone, tap each tile to feel the seven kinds; the bars draw each vibration pattern and the line under the card shows what haptic() returned. Try it at [Haptics](https://wingo-ui.com/components/haptics) and install it with `npx wingo-ui@latest add haptics`.

A caveat about our own code: the helper's `iosFallback` option, on by default, still clicks a hidden label from script. That plays on iPhones up to iOS 26.4, is ignored from 26.5, and the call still returns `true` there because WebKit reports nothing back. If you add the tap overlay above, call `configureHaptics({ iosFallback: false })` once so iPhones on older versions do not tick twice.

The snippets below import Wingo UI's helper. With the dependency-free file above, import `vibrate` from `@/lib/vibrate` instead and use `setHapticsEnabled` in place of `configureHaptics`; it has no `force` option.

## Which haptic should each interaction get?

One tick per event, sized to the event. These are the patterns the helper sends on Android:

| Kind | Pattern (ms) | Use it for | Wingo UI component that plays it |
| --- | --- | --- | --- |
| `selection` | 4 | a row of a wheel or a slider step passing the center | Picker Wheel |
| `light` | 8 | a tap, a toggle | Haptic Button press |
| `medium` | 14 | a long press, a snap, a crossed threshold | Haptic Button long press, Pull To Refresh arming, Slide To Confirm armed |
| `heavy` | 22 | a committed full swipe, a drop | none; call it from your own drop handler |
| `success` | 10, 60, 16 | payment sent, item saved | Haptic Button hold confirmed, Slide To Confirm done |
| `warning` | 16, 70, 16 | a limit reached, a hold released too early | Haptic Button tap in hold mode |
| `error` | 22, 50, 22, 50, 22 | a wrong PIN, a failed save | Haptic Button rejected promise |

Three rules keep it from turning into noise. Call it from the handler of a real gesture, never from an effect or a render. Never tick per animation frame; tick when something changes state, like a row reaching the center. And never put information only in the tick: desktop users, iPhone users on a wheel and anyone with a phone on silent will not feel it, so the screen must say the same thing.

## How do you add React haptic feedback to a switch, a button and a wheel?

Call `haptic()` in the handler where the state changes, once per change.

### A switch

The free [Switch](https://wingo-ui.com/components/switch) calls `onCheckedChange` on every click, key or drag, and a returned promise makes it spin, then flip back with `errorText` if the save fails. Tick on the change and again on failure:

```tsx
"use client";

import { Switch } from "@/components/ui/switch";
import { haptic } from "@/lib/haptics";

export function NewMessagesSetting({ save }: { save: (on: boolean) => Promise<void> }) {
  return (
    <Switch
      label="New messages"
      description="When someone writes to you about a listing"
      labelPosition="start"
      defaultChecked
      onCheckedChange={async (on) => {
        haptic("light");
        try {
          await save(on);
        } catch (error) {
          haptic("error");
          throw error; // the switch flips back and shows errorText
        }
      }}
    />
  );
}
```

On iOS 26.5 and later this Switch stays silent: it is a Radix button with `role="switch"`, not the native input, so no system tick comes with the tap, and `haptic()` cannot reach the switch tick from script. If an iPhone tick on a settings toggle matters more to you than the custom look, a plain `<input type="checkbox" switch>` ticks on its own and renders as a checkbox in browsers that do not know the attribute.

### A button

The [Haptic Button](https://wingo-ui.com/components/haptic-button) plays its `hapticKind` (default `light`) on touch and pen presses only, so a mouse click never asks for a vibration. In `mode="hold"` it plays light when the hold starts, success once `onConfirm` has finished, warning for a tap that was too short and error when `onConfirm` rejects; with `onLongPress` set, a long press plays medium.

```tsx
"use client";

import { Send } from "lucide-react";
import { HapticButton } from "@/components/ui/haptic-button";

export function SendRent({ onSend }: { onSend: () => Promise<void> }) {
  return (
    <HapticButton mode="hold" fullWidth leftIcon={<Send />} labels={{ holdHint: "Hold to send" }} onConfirm={onSend}>
      Hold to send $1,850
    </HapticButton>
  );
}
```

> Live demo (Haptic Button): Hold the Hold to send button with a finger on an Android phone: a light tick starts the fill, a success pattern lands with the check, and a quick tap shakes the button with a warning tick. Try it at [Haptic Button](https://wingo-ui.com/components/haptic-button) and install it with `npx wingo-ui@latest add haptic-button`.

For an ordinary [Button](https://wingo-ui.com/components/button) that should tick on both platforms, combine the call and the overlay. The Button is already `position: relative`, so the overlay covers it:

```tsx
"use client";

import { Button } from "@/components/ui/button";
import { IOSTapTick } from "@/components/ios-tap-tick";
import { haptic } from "@/lib/haptics";

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  return (
    <Button
      onClick={async () => {
        haptic("light"); // android; the overlay ticks on iPhone
        try {
          await onSave();
          haptic("success"); // android only: iPhone has no tick after an await
        } catch {
          haptic("error");
        }
      }}
    >
      Save
      <IOSTapTick />
    </Button>
  );
}
```

### A wheel

The [Picker Wheel](https://wingo-ui.com/components/picker-wheel) plays a `selection` tick each time a new row reaches the center while a finger, a mouse drag or a scroll wheel drives it, and never for keys, clicks or value changes from outside. `haptics={false}` turns it off.

```tsx
"use client";

import { PickerWheel, pickerRange } from "@/components/ui/picker-wheel";

const hours = pickerRange(8, 19, { pad: 2 });
const minutes = pickerRange(0, 55, { step: 5, pad: 2 });

export function AppointmentTime() {
  return (
    <PickerWheel
      aria-label="Appointment time"
      loop
      defaultValue={["10", "30"]}
      columns={[
        { id: "hour", label: "h", options: hours },
        { id: "minute", label: "min", options: minutes },
      ]}
    />
  );
}
```

> Live demo (Picker Wheel): Flick a column on an Android phone: each row that reaches the center ticks once, and the value changes when the wheel comes to rest. Try it at [Picker Wheel](https://wingo-ui.com/components/picker-wheel) and install it with `npx wingo-ui@latest add picker-wheel`.

This is the clearest case of the platform gap. On Android every row that passes the center ticks. On an iPhone the wheel is silent from iOS 26.5, because a spin is a scroll and never produces the trusted click the switch needs; the band, the tilt and the snap carry the feedback there.

## How do you let people turn haptics off?

Give them a setting and pass it to the helper once. Every haptic feedback web app needs one: some people find vibration distracting, and the browser gives a page no way to read the phone's own haptics setting.

```tsx
"use client";

import { useEffect, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { configureHaptics, haptic } from "@/lib/haptics";

export function VibrationSetting() {
  const [on, setOn] = useState(true);

  useEffect(() => {
    configureHaptics({ enabled: on });
  }, [on]);

  return (
    <Switch
      label="Vibrations"
      description="A short tick when you tap, hold or pick"
      labelPosition="start"
      checked={on}
      onCheckedChange={(next) => {
        setOn(next);
        // the effect has not run yet, so force the confirmation tick
        if (next) haptic("light", { force: true });
      }}
    />
  );
}
```

Store the value with the rest of the user's settings. Every component that ticks through the helper follows it without a prop: the Haptic Button, the Picker Wheel, and the Pull To Refresh from our [pull to refresh guide](https://wingo-ui.com/blog/react-pull-to-refresh) among them.

## What comes after navigator.vibrate?

A semantic API with named effects, if browsers adopt the one being drafted now. The [Web Haptics API](https://github.com/WICG/web-haptics) started as a [proposal to the CSS Working Group](https://github.com/w3c/csswg-drafts/issues/13728) in March 2026 and has been incubated at the WICG since April. Its draft has a `navigator.playHaptics(effect, intensity)` call and a nested `@haptic` CSS rule, with six named effects (hint, edge, tick, align, success, error) and an intensity from 0 to 1 that the browser maps to the platform's own haptics. The call returns `undefined`, so a page learns nothing about the device. As of October 2026 [Chrome Platform Status](https://chromestatus.com/feature/5167864517623808) lists Web haptics as Proposed, and no stable browser ships it.

The shape is familiar: named kinds instead of millisecond patterns. If your app already funnels every tick through one `haptic(kind)` call, moving to a new API later means changing one file.

## Where should you start?

Add a helper first, then tick the three places users touch most: toggles, the primary button and any wheel or slider. The dependency-free `lib/vibrate.ts` above covers Android. `npx wingo-ui@latest add haptics` copies Wingo UI's `lib/haptics.ts` instead; it is part of [Wingo UI Pro](https://wingo-ui.com/pricing), like the Haptic Button and the Picker Wheel, while the Switch is free. For the rest of the phone checklist (bottom sheets, [44px touch targets](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind), [safe area insets](https://wingo-ui.com/blog/tailwind-safe-area-insets)), read [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components) or browse the [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) category.

## Components in this post

- [Haptics](https://wingo-ui.com/components/haptics) (Pro): Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else. Install: `npx wingo-ui@latest add haptics`
- [Haptic Button](https://wingo-ui.com/components/haptic-button) (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: `npx wingo-ui@latest add haptic-button`
- [Switch](https://wingo-ui.com/components/switch) (Free): An on / off setting that applies at once: a spring thumb that stretches and drags like iOS, thumb icons, and async saves that roll back. Install: `npx wingo-ui@latest add switch`
- [Picker Wheel](https://wingo-ui.com/components/picker-wheel) (Pro): The iOS wheel picker for the web: columns that spin with native momentum and click into place on a 3D cylinder, inline or as a field. Install: `npx wingo-ui@latest add picker-wheel`

## FAQ

### Does navigator.vibrate work on iPhone?

No. Safari has never implemented the Vibration API, and as of October 2026 caniuse lists no version of Safari on iOS that supports it. The only haptic a web page can produce on an iPhone is the system tick of a native switch input, and since iOS 26.5 only the user's own tap on it plays that tick.

### Why does navigator.vibrate return true on my laptop?

Because true only means the browser accepted the pattern. Desktop Chrome exposes navigator.vibrate and, once the page has seen a click, returns true for a valid pattern even though there is no motor to run. Use the return value to detect refusals and never to detect hardware.

### Why does navigator.vibrate do nothing on my Android phone?

The usual causes are a page the user has not tapped yet (Chrome blocks the call and logs a warning until the page has sticky user activation), a hidden tab, silent or Do Not Disturb mode, or Firefox, which removed the API in version 129.

### Can a web page control vibration intensity?

Not with the Vibration API, which only switches the motor on and off for the durations you pass. The Web Haptics API proposal, incubated at the WICG since April 2026, adds named effects with an intensity from 0 to 1, but as of October 2026 no stable browser ships it.

### Is the Wingo UI haptics helper free?

No. The haptics helper, the Haptic Button and the Picker Wheel are Pro items; the Switch is free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add haptics copies lib/haptics.ts into your project.

---

Source: https://wingo-ui.com/blog/web-vibration-api-haptic-feedback
