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
- 触感是手势的回声 —— 每一次
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 不该因为一个兄弟组件消失而中断。
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.
Pricing Table
A four-state pricing section with a data-derived monthly/yearly toggle and a highlighted recommended tier.