Dev Blog

BLOG

Making Android phones vibrate on the beat

A setting that vibrates Android phones on each beat. How the vibration stays in time with the sound, why iPhones and Firefox on Android don't vibrate, and why my own phone didn't vibrate at first (the device's haptic feedback setting).

Published October 10, 2026

Android phones can now vibrate on the beat (iPhones can't; I explain why below). It helps when you practice in a loud room, or when following the beat by ear alone is hard. Muting all sound in the audio settings doesn't stop the vibration, so you can keep time by vibration alone, with no sound.

Vibrating from the browser is just a call to navigator.vibrate(), but keeping it in sync with the sound took some work. Fewer browsers support it than I expected, and on a real phone I also got stuck when the vibration request went through but nothing vibrated. This post covers those three things: staying in sync with the sound, which browsers vibrate, and why my phone didn't vibrate at first.

Where the setting is

The audio settings, which open when you press SOUND in the bar at the bottom of the screen, have a "Vibrate on beat" row. When it's on, accent beats (the beats played stronger) vibrate for 60 ms and normal beats (all the others) for 25 ms. Beats you've muted in the beat settings (beats that play no sound) don't vibrate. With a custom beat, it vibrates at every position where a click sounds.

The audio settings at phone width. Number 1 marks the Vibrate on beat row below Mute, switched on
Audio settings (as shown in Chrome on Android)
  1. Vibrate on beat. It only appears where vibration works

The Web Vibration API only lets you choose how long to vibrate, and a pattern of vibrating and pausing. You can't set the strength (amplitude). Accent and normal beats differ by length only.

Vibrating in sync with the sound

The app plays its sound on the audio thread of an AudioWorklet. The audio thread also counts the beats and sends the main thread a message saying "this beat sounds at this time". The pendulum display uses those messages to move in sync with the sound.

navigator.vibrate() can't be called from the audio thread, only from the main thread. So I added the time of each click, and whether it's an accent (pulses), to the beat message. A custom beat can click several times within one beat, so one beat's message can carry several pulses.

// While working out the clicks for each beat, also collect the time and strength for vibration
if (event.beat.state !== "muted") {
  const accent = event.beat.state === "accent";
  const same = pulses.findIndex((pulse) => pulse.time === soundTime);
  if (same === -1) {
    pulses.push({ time: soundTime, accent });
  } else if (accent) {
    // If an accent and a normal click overlap in a custom beat, use the accent
    pulses[same] = { time: soundTime, accent };
  }
}

The message reaches the main thread a little before the sound is played. On top of that, you actually hear the sound later by the output latency (outputLatency). On the main thread, the app works out which point on the audio clock is coming out of the speaker right now, waits from there until the click's time, and then vibrates.

const context = audioEngine.getAudioContext();
// Sound is heard later by the output latency, so find the time being heard now
const now = context.currentTime - (context.outputLatency || 0);
for (const pulse of event.pulses) {
  const delayMs = (pulse.time - now) * 1000;
  // Skip vibrations more than 30 ms late; they would feel off
  if (delayMs < -30) continue;
  setTimeout(
    () => navigator.vibrate(pulse.accent ? 60 : 25),
    Math.max(0, delayMs),
  );
}

setTimeout is delayed when the main thread is busy, so the vibration is less precise than the sound.

When you stop playback, the app cancels the scheduled vibrations and calls navigator.vibrate(0) to stop any vibration. While the page is in the background the browser won't vibrate, so nothing is scheduled.

Muting all sound in the audio settings only stops the audio thread from making sound; the beat messages keep coming. That's why vibration keeps going while muted.

Only Chrome and similar browsers on Android vibrate

When I checked support before building it, the only browsers that actually vibrate were Chrome and other Chromium-based browsers on Android.

Environment
navigator.vibrate
Vibrates
Chrome and similar on Android
Present
Yes
iPhone and iPad (any browser)
Missing
No
Firefox on Android
Present
No
Chrome and similar on a computer
Present
No (no vibration motor)

Safari on iPhone has never supported the Vibration API. Every browser on iPhone, Chrome included, runs on WebKit, so they all behave the same.

Since iOS 17.4, toggling an <input type="checkbox" switch> gives haptic feedback. A workaround spread that toggled it from JavaScript to vibrate, but it's reported to have been closed in iOS 26.5. Haptics now only fire when a finger presses the switch. A metronome ticks on a timer, so this approach didn't work. Vibrating on iPhone needs a native app.

Firefox on Android has navigator.vibrate, but calling it doesn't vibrate. Firefox is reported to check for a vibration permission without ever showing a prompt to ask for it, so the call is treated as denied. The W3C's 2024 implementation report for the Vibration API (test results for each browser) also found that only Chrome on Android passed the tests.

So the row is shown only under these conditions.

export const isVibrationSupported = ({
  hasVibrate, // "vibrate" in navigator
  isCoarsePointer, // matchMedia("(pointer: coarse)").matches
  userAgent,
}) => hasVibrate && isCoarsePointer && !/Firefox\//.test(userAgent);

Chrome on a computer has navigator.vibrate too, so I limited it to touch-first devices (pointer: coarse). Firefox is excluded by its User-Agent. I'm not a fan of User-Agent sniffing, but it seemed better than showing a setting that does nothing when turned on.

On a real phone, the cause was the haptic feedback setting

When I tried it on my Android phone (a Redmi Note 13 Pro 5G running Xiaomi's HyperOS), turning it on and pressing play didn't vibrate at all.

To check whether Metronome.fm's code was at fault, I made a page with a button that only calls navigator.vibrate(500). It returned true, and the phone still didn't vibrate. true means the browser accepted the request; it doesn't guarantee the motor ran.

The cause was that haptic feedback was off in the phone's settings. After turning it on, Metronome.fm vibrated too. On HyperOS, it seems that turning haptic feedback off also blocks vibration from the browser.

A web page has no way to tell whether the phone's settings are blocking vibration. The manual now says: if nothing vibrates after you turn it on, check the phone's haptic feedback setting.

Tests

In the E2E tests, Playwright's hasTouch and isMobile emulate a touch device, and navigator.vibrate is replaced with a function that records calls. The test plays at 120 BPM in 4/4 and checks that the first four calls are 60, 25, 25, 25. It also checks that vibration is called while muted, and that the row doesn't appear on a computer screen.

await page.addInitScript(() => {
  window.__vibrations = [];
  Object.defineProperty(Navigator.prototype, "vibrate", {
    configurable: true,
    value: (pattern) => {
      window.__vibrations.push(pattern);
      return true;
    },
  });
});

Still, tests can't catch a call that succeeds without vibrating, like on my phone. In the end, it had to be checked on a real device.

Share this post

Post on X