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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-vibrate.jsonPrompt
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 refusals —
navigator.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 (unsupportedhas no way back,mutedis the user's wish,no-activationmeans 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. SoisVibratingand the mute switch live in module scope and are distributed withuseSyncExternalStore; 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
visibilitychangeto 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.
useBattery
Reads battery level, charging state and time remaining from the Battery Status API, with a four-way support status and a low-power flag that fails open when no reading is available.
useFormPersist
A form-draft hook: debounced writes into a versioned storage envelope, an explicit restore offer at mount, and a one-call clear after a successful submit.