WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Web Vibration API: Haptic Feedback on Android and iPhone
  1. Blog
  2. Mobile UI in React
  3. Web Vibration API: Haptic Feedback on Android and iPhone
Mobile UI in React
Mobile UI in React

Web Vibration API: Haptic Feedback on Android and iPhone

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. What can the Web Vibration API do in 2026?
  2. How does navigator.vibrate behave on Android?
  3. Does the Web Vibration API work on iOS?
  4. How do you call navigator.vibrate safely where it is unsupported?
  5. Which haptic should each interaction get?
  6. How do you add React haptic feedback to a switch, a button and a wheel?
    1. A switch
    2. A button
    3. A wheel
  7. How do you let people turn haptics off?
  8. What comes after navigator.vibrate?
  9. Where should you start?
  10. FAQ
    1. Does navigator.vibrate work on iPhone?
    2. Why does navigator.vibrate return true on my laptop?
    3. Why does navigator.vibrate do nothing on my Android phone?
    4. Can a web page control vibration intensity?
    5. Is the Wingo UI haptics helper free?

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.

Published Oct 9, 2026

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 (opens in a new tab) 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 (opens in a new tab) for support, our own test in desktop Chromium for the last row):

Where the page runsnavigator.vibrateWhat the user feels
Chrome or Samsung Internet on Androidsupportedthe pattern you pass, once the page has seen a tap
Firefoxremoved in version 129, not supported on Androidnothing
Safari on iPhone, and home screen web appsnever shippedthe system tick of a native switch, only when the user's own tap toggles it
Chrome on a desktoppresent, returns truenothing, 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 (opens in a new tab):

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 (opens in a new tab) 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 (opens in a new tab)), 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 (opens in a new tab) says the trick "only works on ios 17.4 to 26.4, as apple patched it in ios 26.5". The WebKit fix (opens in a new tab), 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 (opens in a new tab) 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 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).

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.
$ npx wingo-ui@latest add haptics
ProHaptics docs

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:

KindPattern (ms)Use it forWingo UI component that plays it
selection4a row of a wheel or a slider step passing the centerPicker Wheel
light8a tap, a toggleHaptic Button press
medium14a long press, a snap, a crossed thresholdHaptic Button long press, Pull To Refresh arming, Slide To Confirm armed
heavy22a committed full swipe, a dropnone; call it from your own drop handler
success10, 60, 16payment sent, item savedHaptic Button hold confirmed, Slide To Confirm done
warning16, 70, 16a limit reached, a hold released too earlyHaptic Button tap in hold mode
error22, 50, 22, 50, 22a wrong PIN, a failed saveHaptic 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 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 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>
);
}
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.
$ npx wingo-ui@latest add haptic-button
ProHaptic Button docs

For an ordinary 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 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 },
]}
/>
);
}
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.
$ npx wingo-ui@latest add picker-wheel
ProPicker Wheel docs

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 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 (opens in a new tab) started as a proposal to the CSS Working Group (opens in a new tab) 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 (opens in a new tab) 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, 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, safe area insets), read mobile-first React components or browse the Mobile UI in React category.

Components in this post

  • Haptics

    Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else.

    Pro
  • Haptic Button

    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.

    Pro
  • Switch

    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.

    Free
  • Picker Wheel

    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.

    Pro

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.

  • Mobile UI
  • Haptics
  • Vibration API
  • iOS
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Minimum Touch Target Size: WCAG, iOS, Android and Tailwind

Minimum touch target size per WCAG 2.2 (24 and 44px), iOS (44pt) and Android (48dp), and how to get 44px taps in React and Tailwind without desktop bloat.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Mobile UI in React

React Pull to Refresh in a PWA Without Fighting the Browser

Build a React pull to refresh for a PWA: overscroll-behavior, rubberband resistance, iOS spokes or a Material ring, a haptic tick and a refresh button.

SRSerban Rusu·Oct 9, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI