An open API service indexing awesome lists of open source software.

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.

Awesome Lists containing this project

README

          

# haptics


CI
npm downloads
bundle size
license

**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) | [![npm](https://img.shields.io/npm/v/@haptics/core)](https://www.npmjs.com/package/@haptics/core) | 1.33 KB | Framework-agnostic engine |
| [`@haptics/react`](./packages/react) | [![npm](https://img.shields.io/npm/v/@haptics/react)](https://www.npmjs.com/package/@haptics/react) | 1.04 KB | React bindings |
| [`@haptics/vue`](./packages/vue) | [![npm](https://img.shields.io/npm/v/@haptics/vue)](https://www.npmjs.com/package/@haptics/vue) | 1.33 KB | Vue 3 bindings |
| [`@haptics/svelte`](./packages/svelte) | [![npm](https://img.shields.io/npm/v/@haptics/svelte)](https://www.npmjs.com/package/@haptics/svelte) | 1.09 KB | Svelte 5 bindings |
| [`@haptics/vanilla`](./packages/vanilla) | [![npm](https://img.shields.io/npm/v/@haptics/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.