Hooks

useVibrate

A Vibration API hook that plays a normalized haptic pattern from a user gesture, names every refusal instead of failing silently, honours a page-wide haptics mute, and cancels the pattern it owns on unmount.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The shape `navigator.vibrate` takes: a single millisecond count, or an array of
 * alternating buzz / pause milliseconds (**even indices buzz, odd indices pause**).
 * `readonly` so a consumer can hand over an `as const` preset directly, without
 * copying it first.
 */
export type VibrationPattern = number | readonly number[]

/**
 * Why a `vibrate()` call never reached the hardware. Listed **in evaluation order**,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-vibrate.json

Prompt

Build a React + TypeScript "useVibrate" hook (React only — no npm dependencies;
browser Vibration API, matchMedia, navigator.userActivation).

Contract
- `useVibrate(options?: {
    pattern?: number | readonly number[]   // default 20
    muted?: boolean                        // default false
    respectReducedMotion?: boolean         // default true
    requireUserActivation?: boolean        // default true
    maxDuration?: number                   // default 5000 (ms, total)
  }): { vibrate, stop, isSupported, isVibrating, isMuted, prefersReducedMotion }`
- `vibrate(pattern?: number | readonly number[]): VibrateOutcome` — never throws,
  never returns a bare boolean. `VibrateOutcome` is
  `{ ok: boolean; reason: VibrateRefusal | null; pattern: number[];
  duration: number; clamped: boolean }`, and every field is meaningful in BOTH
  outcomes: on a refusal, `pattern` / `duration` describe what would have been
  sent, which is exactly what a log line or a "haptics are off" hint needs.
- `VibrateRefusal = "empty-pattern" | "unsupported" | "muted" | "reduced-motion"
  | "no-activation" | "rejected"`, evaluated in that order, first match wins.
- `stop(): void` cancels whatever the page is currently playing (see one channel).
- `isSupported` — the API exists here. `isVibrating` — the page's channel is
  busy. `isMuted` — global mute OR this instance's `muted`. `prefersReducedMotion`
  — live from the media query.
- Module scope, not per instance: `setHapticsMuted(next: boolean): void` and
  `getHapticsMuted(): boolean`. Haptics is an app-level preference; a settings
  switch must be able to silence every instance without threading a prop through
  the tree. Persistence is deliberately NOT built in — read your storage on boot
  and call `setHapticsMuted` once.
- `vibrate` and `stop` keep one identity for the component's whole life (options
  live in a latest-ref, not in a dependency array), so an inline
  `pattern={[30, 60, 30]}` never invalidates a memoized child.

Behavior
- Pattern shape is the platform's: a single number, or alternating
  buzz/pause milliseconds where EVEN indices buzz and ODD indices pause.
- Normalize before deciding anything: coerce each entry with
  `Number.isFinite(v) ? Math.max(0, Math.round(v)) : 0`, accumulate against
  `maxDuration` and truncate the entry that crosses it (`clamped: true` — "too
  long" is usually a miscomputed loop, not a wish to be refused), then pop
  trailing entries while the array length is even (a trailing pause) or the last
  entry is 0. Normalizing rather than rejecting matters because
  `navigator.vibrate()` is all-or-nothing: one NaN, one negative, one fractional
  value and the platform voids the WHOLE call — the user's haptic is lost to an
  intermediate value they never see.
- Refusal order and why: `empty-pattern` first (a caller bug — nothing to play
  no matter how good the environment), then `unsupported` (no
  `navigator.vibrate`: SSR, iOS/iPadOS Safari), then `muted` (the user's stated
  wish outranks everything below it), then `reduced-motion`, then
  `no-activation`, and finally `rejected` when the platform itself answered
  `false`.
- `no-activation`: Chromium silently drops `vibrate()` on a document that has
  never been interacted with, logging only to the console. Detect it with
  `navigator.userActivation.hasBeenActive` and turn that silent drop into a
  named refusal — but only refuse when you can positively tell. Engines without
  `navigator.userActivation` (Safari) must not be guessed at; let the platform
  decide. This guard is also the mechanical statement of the hook's rule:
  vibration accompanies a gesture, it does not announce background events.
- `reduced-motion`: there is no `prefers-reduced-haptics` on the web.
  `prefers-reduced-motion: reduce` is the only standing "give me less physical
  stimulation" signal, so honour it by default and let a consumer opt out when
  the haptic is an accessibility affordance rather than decoration. Read it live
  inside `vibrate()` (matchMedia), not from the render snapshot.
- One hardware channel per page: a second `vibrate()` REPLACES the pattern still
  playing, and `vibrate(0)` cancels whatever is playing regardless of who
  started it. Therefore `isVibrating` and the mute flag live in module scope and
  are distributed with `useSyncExternalStore`; per-instance copies would lie the
  moment two components buzz. Track the owning instance with a lazily assigned
  id read AND written synchronously inside the handler (`ref.current ||= ++seq`)
  so "who started this" has exactly one answer.
- The platform fires no "vibration finished" event, so `isVibrating` runs off a
  soft `setTimeout(duration)`. Say so in the code: clearing that timer does not
  stop the motor, it only stops the UI from claiming a buzz is still running.
- The document going hidden cancels vibration per spec. Subscribe once to
  `visibilitychange` (attached when the store gets its first listener, removed
  when it loses its last) and clear the run state instead of letting the soft
  timer keep the indicator lit.
- `setHapticsMuted(true)` cancels the pattern currently playing — a mute switch
  that lets the buzz in the user's hand finish is the failure people complain
  about.
- Cleanup: on unmount cancel ONLY if this instance owns the running pattern;
  another component's haptic must not die because a sibling unmounted. Never
  vibrate on mount, in an effect, or on a timer.
- SSR/hydration: every environment read goes through `useSyncExternalStore` with
  a server snapshot (`isSupported: false`, `isVibrating: false`, `isMuted:
  false`, `prefersReducedMotion: false`); nothing touches `navigator`,
  `document` or `matchMedia` during render, so there is no hydration mismatch
  and no `typeof window` guard at module top level.

Rendering & styling
- The hook renders nothing and owns no DOM. Consumer rules that keep it honest:
  every buzz must be accompanied by a visible change (a key going down, a row
  leaving, a badge flipping) — a haptic with no on-screen cause is
  indistinguishable from a broken device; and the feature must work fully with
  haptics refused, because on iPhone, on any desktop, and for any muted user
  that is the normal path.
- Feedback UI uses semantic tokens only: `bg-primary` / `text-primary` for the
  live channel indicator, `bg-muted` / `text-muted-foreground` for idle and for
  policy refusals, `text-destructive` for `unsupported` / `rejected`, `border`
  and `bg-card` for the surrounding surface.
- Any pulsing indicator gets `motion-reduce:animate-none`; the state it reports
  must stay readable without the animation.
- Controls that can become unavailable (a `stop()` button with an idle channel,
  a confirm key with an incomplete entry) use `aria-disabled` plus an early
  return in the handler, never the native `disabled` attribute — the browser
  blurs a disabled control and drops focus to `<body>` under the user's hands.
- Good magnitudes: 10–50ms is feedback, 100–200ms is an alert, over a second is
  harassment. Presets worth shipping: tap `[12]`, double `[12, 60, 12]`, success
  `[24, 48, 24]`, warning `[40, 60, 40]`, error `[90, 70, 180]`.

Customization levers
- `maxDuration` is the conscience dial: lower it to 300 for a UI that should
  only ever tick, raise it for a timer/alarm surface that legitimately needs a
  long buzz.
- Turn `respectReducedMotion` off when the haptic is the accessibility channel
  (confirming a keypress for a low-vision user) and gate it on your own setting
  instead; turn `requireUserActivation` off only if you have your own activation
  tracking and want the platform to make the call.
- Swap the refusal set for your telemetry: the union is the extension point —
  add `"battery-saver"` or `"quiet-hours"` as extra guards before the platform
  call and they flow through `VibrateOutcome.reason` unchanged.
- Wrap `setHapticsMuted` in your own persistence (localStorage, server-side user
  preference) and call it once at boot; keep the module store as the single
  runtime source of truth so no component has to receive a `muted` prop.
- Presets belong to the app, not the hook: export a `HAPTICS` map of named
  patterns next to your design tokens so `vibrate(HAPTICS.success)` reads like
  the rest of your system, and set `options.pattern` to the one a given
  component uses most so its call sites can stay `vibrate()`.

Concepts

  • A haptic is the echo of a gesture — every vibrate() should sit on an action the user just finished. Three hard reasons: Chromium drops the request outright on a document that was never interacted with (so "buzz when the background job finishes" mostly never fires on a real device); with nothing changing on screen at the same moment, that jolt in a pocket and "the device is broken" are the same experience; and a haptic bypasses vision to act on the body directly, which costs far more than a toast. Announcing something the user is not watching is a desktop notification's job.
  • Named refusalsnavigator.vibrate() answers with one boolean and never says why, yet "it did not buzz" has six completely different causes and only some of them are yours to fix. Once they are named apart (unsupported has no way back, muted is the user's wish, no-activation means you hung the call in the wrong place), your logs and UI can say something useful instead of "vibration failed".
  • Normalize, do not reject — the platform treats a pattern as all-or-nothing: one NaN, one negative, one fractional value voids the entire call. So coerce every entry to a non-negative integer in place, truncate whatever crosses the ceiling, trim the trailing pause, and only then hand it over — the user's feel should not pay for one miscomputed intermediate value.
  • One channel, page-level state — there is only one motor: a second call replaces the pattern still playing, and vibrate(0) cancels the whole page's vibration rather than just your own. So isVibrating and the mute switch live in module scope and are distributed with useSyncExternalStore; a copy per instance starts lying the moment two components buzz in sequence.
  • Mute is an app-level preference — a user who turns off "haptic feedback" in settings expects the whole app to go quiet, not a prop threaded through every component; hence the module-level setHapticsMuted, which must cancel the pattern currently playing on the spot. Persistence is deliberately left outside the hook: whether you use localStorage or a server-side preference is not for it to guess.
  • Cleanup discipline — the platform cancels vibration itself when the document goes to the background, and the hook listens to visibilitychange to sync the state back; on unmount it cancels only the pattern it started, since another component's should not die because a sibling went away.

On This Page