https://github.com/howdoiusekeyboard/haptics
Haptic feedback for React web apps. iOS Safari + Android Chrome.
https://github.com/howdoiusekeyboard/haptics
haptic-feedback haptics ios mobile react safari typescript vibration
Last synced: 15 days ago
JSON representation
Haptic feedback for React web apps. iOS Safari + Android Chrome.
- Host: GitHub
- URL: https://github.com/howdoiusekeyboard/haptics
- Owner: howdoiusekeyboard
- License: mit
- Created: 2026-03-20T08:02:38.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-05-20T17:10:35.000Z (19 days ago)
- Last Synced: 2026-05-20T20:33:55.599Z (19 days ago)
- Topics: haptic-feedback, haptics, ios, mobile, react, safari, typescript, vibration
- Language: TypeScript
- Homepage:
- Size: 88.9 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# haptics
**Haptic feedback for the web — actually works on iOS Safari.**
~1 KB gzip per adapter. React, Vue, Svelte, or any framework.
> [!NOTE]
> A short demo of the haptic firing on a real iPhone goes here once recorded. See [`assets/hero.gif`](./assets/hero.gif).
## Packages
| Package | Version | Size (gzip) | Description |
| --- | --- | --- | --- |
| [`@haptics/core`](./packages/core) | [](https://www.npmjs.com/package/@haptics/core) | 1.33 KB | Framework-agnostic engine |
| [`@haptics/react`](./packages/react) | [](https://www.npmjs.com/package/@haptics/react) | 1.04 KB | React bindings |
| [`@haptics/vue`](./packages/vue) | [](https://www.npmjs.com/package/@haptics/vue) | 1.33 KB | Vue 3 bindings |
| [`@haptics/svelte`](./packages/svelte) | [](https://www.npmjs.com/package/@haptics/svelte) | 1.09 KB | Svelte 5 bindings |
| [`@haptics/vanilla`](./packages/vanilla) | [](https://www.npmjs.com/package/@haptics/vanilla) | 0.89 KB | Zero-framework |
## The problem
Mobile browsers have two haptics paths, and both have friction:
- **Android**: `navigator.vibrate()` works, but every component needs to call it manually and there's no pattern abstraction
- **iOS**: Safari never implemented the Vibration API. The only web haptics path is the `` trick — but React 18's concurrent scheduler breaks the native gesture chain required for it to fire
## How this works
On iOS, the library injects an invisible `` overlay as a child of every `[data-haptic]` element. The user's finger lands on the overlay; iOS treats this as direct user interaction with a switch — the only path that survives Apple's iOS 26.5 patch — and fires native haptic feedback. The library re-dispatches the click to the host so consumer `onclick` handlers still run. iOS 17.4 – 26.4 additionally schedules subsequent ticks for multi-segment patterns via programmatic clicks on the same overlay; on iOS 26.5+ those programmatic clicks no-op and patterns degrade to a single tick.
On Android, the standard Vibration API is used with full pattern support.
A `MutationObserver` watches for dynamically-added `[data-haptic]` elements so SPAs and lazy-loaded components are picked up automatically. Elements opt in with a single attribute — no per-component wiring.
## Install
```bash
npm install @haptics/react # React
npm install @haptics/vue # Vue 3
npm install @haptics/svelte # Svelte 5
npm install @haptics/vanilla # No framework
npm install @haptics/core # Engine only
```
> The legacy `react-haptics` and placeholder `svelte-haptics` packages are deprecated on npm. Existing `react-haptics` installs continue to function (it re-exports from `@haptics/react`), but new projects should install `@haptics/react` directly.
## Usage
Wrap your app with `HapticsProvider`:
```tsx
import { HapticsProvider } from "@haptics/react";
export default function App({ children }) {
return {children};
}
```
Add `data-haptic` attributes to interactive elements:
```tsx
Submit
Delete
Settings
```
Or trigger imperatively via the hook:
```tsx
import { useHaptics } from "@haptics/react";
function SaveButton() {
const { trigger } = useHaptics();
const handleSave = async () => {
const ok = await save();
trigger(ok ? "success" : "error");
};
return (
Save
);
}
```
### Vue
```ts
import { HapticsPlugin } from "@haptics/vue";
app.use(HapticsPlugin);
```
```vue
Save
```
Or use the composable:
```ts
const { trigger } = useHaptics();
trigger("success");
```
### Svelte
```svelte
import { setupHaptics, haptic } from '@haptics/svelte';
setupHaptics();
Save
```
### Vanilla JS
```ts
import { Haptics } from "@haptics/vanilla";
const haptics = new Haptics();
// Any now triggers haptics on click
// Or imperatively: haptics.trigger("success");
```
## Presets
| Name | Feel | Use case |
| --- | --- | --- |
| `selection` | Light tick | Toggles, minor state changes |
| `impact-light` | Subtle tap | Gentle acknowledgment |
| `impact-medium` | Standard tap | Button presses, navigation |
| `impact-heavy` | Strong tap | Destructive actions, confirmations |
| `success` | Rising confirmation | Form submit, save complete |
| `warning` | Attention pulse | Validation warning |
| `error` | Sharp rejection | Failed action, critical error |
## Custom patterns
```tsx
import { HapticsProvider } from "@haptics/react";
const patterns = {
"card-tap": [
{ duration: 12, intensity: 0.5 },
{ delay: 20, duration: 12, intensity: 0.5 },
{ delay: 20, duration: 12, intensity: 0.5 },
],
};
Tap me
;
```
Custom patterns are merged with built-in presets. Same-name customs override the preset.
## Configuration
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `respectReducedMotion` | `boolean` | `false` | Suppresses haptics when `prefers-reduced-motion: reduce` is active. Default is off because the CSS query targets visual animation, not haptic feedback — iOS has a dedicated System Haptics toggle. Opt in if you want Reduce Motion to also gate haptics. |
| `patterns` | `Record` | `{}` | Custom patterns merged with built-in presets |
## Platform support
| Platform | Mechanism | Notes |
| --- | --- | --- |
| iOS Safari 17.4 – 26.4 | Switch overlay + programmatic re-tick | Full multi-tick patterns. Requires system haptics enabled. |
| iOS Safari 26.5+ | Switch overlay (single tick) | One tick per user tap. Apple's 26.5 patch closed every programmatic-toggle path, so multi-segment presets degrade to single-tick. Single-tick presets (`selection`) are unaffected. |
| Android Chrome / Edge | `navigator.vibrate()` | Full pattern support with timing sequences. |
| Samsung Internet | `navigator.vibrate()` | Full pattern support. |
| Firefox Android | Not supported | Vibration API removed in Firefox 129 (Aug 2024). |
| Desktop | No-op | No haptic hardware. All calls resolve silently. |
## API
### ``
Wraps your app. Registers a capture-phase click listener for iOS haptics. Without it, `data-haptic` attributes won't fire on iOS.
### `useHaptics()`
Returns:
- `trigger(action)` — fire a haptic pattern by name (preset or custom)
- `cancel()` — stop active vibration (Android only)
- `isSupported` — `true` if the Vibration API is available (Android/Chrome)
- `isIOSSupported` — `true` if iOS haptics are available
Works with or without HapticsProvider — falls back to built-in presets.
### Core engine
For framework-agnostic or custom integrations:
```ts
import {
isIOS,
isVibrationSupported,
iosTick,
schedulePattern,
toVibrateSequence,
PRESETS,
} from "@haptics/core";
```
## Bundle size
Sizes measured after minification + gzip (level 9) — what a production bundler will actually ship.
| Package | ESM (min + gz) | CJS (min + gz) |
| --- | --- | --- |
| `@haptics/core` | 1.85 KB | 1.88 KB |
| `@haptics/react` | 0.67 KB | 0.77 KB |
| `@haptics/vue` | 0.67 KB | 0.77 KB |
| `@haptics/svelte` | 0.61 KB | 0.71 KB |
| `@haptics/vanilla` | 0.58 KB | 0.69 KB |
Framework adapter sizes exclude the workspace `@haptics/core` dependency (~1.85 KB min+gz), which is resolved by the consumer's bundler. A typical React consumer ships ~2.52 KB total (adapter + core).
## Limitations
**iOS 26.5+ multi-tick presets** degrade to a single tick. Apple's 26.5 patch closed every programmatic mechanism for firing additional ticks (synchronous `.click()` chains, fresh switches per tick, `setTimeout` chains, stacked switches — all verified to deliver ≤1 buzz). `success`, `error`, `warning`, `impact-light`, `impact-medium`, `impact-heavy` all fire only their first tick on 26.5+. `selection` (single tick) is unaffected. iOS 17.4 – 26.4 retains full multi-tick. Android retains full vibration sequences.
**iOS imperative trigger**: `trigger()` from `useHaptics()` / `createHaptics()` attempts a best-effort iOS haptic via `schedulePattern()`, but it only works when called directly within a user gesture context on iOS 17.4 – 26.4. On iOS 26.5+, programmatic triggers from JS no longer fire — the library's overlay only fires haptic on a real user tap on a `data-haptic` element. For reliable iOS haptics on every version, use declarative `data-haptic` attributes with `HapticsProvider` (React), `HapticsPlugin` (Vue), or `setupHaptics()` (Svelte).
**Re-dispatched click events have `event.isTrusted === false`** on the consumer's `data-haptic` element (iOS path). The user's actual tap lands on the library's invisible switch overlay; the click is then re-dispatched to the host element so consumer `onclick` handlers still run. Consumer code that gates behavior on `isTrusted` (rare — mainly some form libraries and analytics SDKs) won't see these clicks as trusted. The vast majority of click handlers, including every framework's synthetic event system, treat the re-dispatched click identically to a direct one.
**HTML validity of ``**: the iOS overlay is appended as a child of `[data-haptic]` elements. The HTML spec's `` content model excludes interactive descendants, so HTML validators will flag this combination. Every browser renders and clicks it correctly. If you run a validation step in CI, configure it to allow the `data-haptic-overlay` attribute on `` descendants of ``.
**Desktop**: All calls are silent no-ops. No haptic hardware exists on desktop browsers.
**System haptics**: iOS haptics require the user's system haptics setting to be enabled (Settings > Sounds & Haptics > System Haptics).
**Shadow DOM**: The capture-phase listener uses `closest()`, which does not pierce closed shadow trees. Clicks originating inside a closed shadow root match only the host element. If you need `data-haptic` annotations *inside* a shadow tree to fire, attach a `Haptics` instance from `@haptics/vanilla` with `delegateFrom` set to the shadow root.
**Multi-instance state**: The Vue and Svelte adapters store the active config in module-level state — installing the plugin twice in the same JS context (or instantiating multiple Svelte apps) is idempotent, but the most-recently-installed configuration wins for all consumers. The React adapter is per-provider-scope and not affected.
**Pattern length cap**: Patterns are clamped to 64 segments and a total scheduled offset of 60 seconds. Runaway patterns from buggy or untrusted input are truncated rather than queueing thousands of timers.
**`prefers-reduced-motion`**: Not honored by default (changed in 1.1.0). The CSS query targets visual animation, not haptic feedback; iOS provides a separate System Haptics toggle for haptic preference. Pass `respectReducedMotion={true}` if you want Reduce Motion to also gate haptics.
**`event.defaultPrevented`**: The Vue directive and Svelte action skip the haptic when the click was already `preventDefault`'d by an earlier handler. The capture-phase listeners (provider / plugin / setupHaptics) run before bubble-phase `preventDefault` calls, so they always fire — useful for haptics on links that the framework intercepts for client-side navigation.