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"

/**
 * `navigator.vibrate` 吃的形状:一个毫秒数,或者「震 / 停 / 震 / 停…」交替的毫秒数组
 * (**偶数下标是震动,奇数下标是停顿**)。写成 `readonly` 是为了让消费者能把
 * `as const` 的预设常量直接传进来,不用先拷一份。
 */
export type VibrationPattern = number | readonly number[]

/**
 * 一次 `vibrate()` 没落到硬件上的原因。**按判定顺序**排列,先中的先返回:
 *

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

  • 触感是手势的回声 —— 每一次 vibrate() 都应该长在一次用户刚完成的动作上。三条理由是硬的:Chromium 对从没被交互过的文档直接丢弃震动请求(所以「后台任务完成震一下」在真机上多半根本不响);屏幕上没有任何东西同时变化时,口袋里那一下和「设备坏了」是同一种体验;触感绕过视觉直接作用在身体上,成本比一条 toast 高得多。要通知一件用户不在看的事,那是桌面通知的活。
  • 有名字的拒绝 —— navigator.vibrate() 只回一个布尔,不说为什么;而「没震」有六种完全不同的成因,其中只有一部分是你能补救的。把它们分开命名(unsupported 没有回头路、muted 是用户的意愿、no-activation 是你把调用挂错了地方)之后,日志和 UI 才写得出有用的话,而不是一句「震动失败」。
  • 规整而不是判非法 —— 平台对一条 pattern 是全有全无的:一个 NaN、一个负数、一个小数就让整次调用作废。所以先把每一项就地规整成非负整数、把超出上限的部分截断、把尾部的停顿剪掉,再交给平台——用户的手感不该为一个算错的中间值买单。
  • 一条通道,页面级状态 —— 马达只有一个:第二次调用会替换还没播完的 pattern,vibrate(0) 掐掉的是整页的震动而不只是自己那条。所以 isVibrating 与静音开关都放在模块作用域、用 useSyncExternalStore 分发;每个实例各存一份的写法,在两个组件先后震动的那一刻必然开始说谎。
  • 静音是应用级偏好 —— 用户在设置页关掉「震动反馈」,期待的是整个应用安静下来,而不是挨个组件传 prop;开关因此是模块级的 setHapticsMuted,而且必须当场掐掉正在播的 pattern。持久化刻意留在 hook 之外:你用 localStorage 还是服务端偏好,hook 不猜。
  • 收摊纪律 —— 文档切到后台时平台自己取消震动,hook 听 visibilitychange 把状态同步回去;卸载时只取消自己点的那条,别人的 pattern 不该因为一个兄弟组件消失而中断。

On This Page