{"id":50153814,"url":"https://github.com/howdoiusekeyboard/haptics","last_synced_at":"2026-05-24T10:00:35.892Z","repository":{"id":345721588,"uuid":"1186989463","full_name":"howdoiusekeyboard/haptics","owner":"howdoiusekeyboard","description":"Haptic feedback for React web apps. iOS Safari + Android Chrome.","archived":false,"fork":false,"pushed_at":"2026-05-20T17:10:35.000Z","size":91,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-20T20:33:55.599Z","etag":null,"topics":["haptic-feedback","haptics","ios","mobile","react","safari","typescript","vibration"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/howdoiusekeyboard.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-20T08:02:38.000Z","updated_at":"2026-05-20T17:06:46.000Z","dependencies_parsed_at":null,"dependency_job_id":"1a83bffa-73d6-47ca-9c92-e470a7a124f7","html_url":"https://github.com/howdoiusekeyboard/haptics","commit_stats":null,"previous_names":["howdoiusekeyboard/react-haptics"],"tags_count":18,"template":false,"template_full_name":null,"purl":"pkg:github/howdoiusekeyboard/haptics","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/howdoiusekeyboard%2Fhaptics","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/howdoiusekeyboard%2Fhaptics/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/howdoiusekeyboard%2Fhaptics/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/howdoiusekeyboard%2Fhaptics/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/howdoiusekeyboard","download_url":"https://codeload.github.com/howdoiusekeyboard/haptics/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/howdoiusekeyboard%2Fhaptics/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33429192,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T22:14:44.296Z","status":"online","status_checked_at":"2026-05-24T02:00:06.296Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["haptic-feedback","haptics","ios","mobile","react","safari","typescript","vibration"],"created_at":"2026-05-24T10:00:17.138Z","updated_at":"2026-05-24T10:00:35.876Z","avatar_url":"https://github.com/howdoiusekeyboard.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# haptics\n\n\u003cp\u003e\n  \u003ca href=\"https://github.com/howdoiusekeyboard/haptics/actions/workflows/ci.yml\"\u003e\u003cimg src=\"https://github.com/howdoiusekeyboard/haptics/actions/workflows/ci.yml/badge.svg\" alt=\"CI\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/@haptics/react\"\u003e\u003cimg src=\"https://img.shields.io/npm/dm/@haptics/react.svg?label=npm%20downloads\" alt=\"npm downloads\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://bundlephobia.com/package/@haptics/react\"\u003e\u003cimg src=\"https://img.shields.io/bundlephobia/minzip/@haptics/react?label=%40haptics%2Freact%20gzip\" alt=\"bundle size\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/howdoiusekeyboard/haptics/blob/main/LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/github/license/howdoiusekeyboard/haptics\" alt=\"license\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n**Haptic feedback for the web — actually works on iOS Safari.**\n~1 KB gzip per adapter. React, Vue, Svelte, or any framework.\n\n\u003e [!NOTE]\n\u003e A short demo of the haptic firing on a real iPhone goes here once recorded. See [`assets/hero.gif`](./assets/hero.gif).\n\n## Packages\n\n| Package | Version | Size (gzip) | Description |\n| --- | --- | --- | --- |\n| [`@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 |\n| [`@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 |\n| [`@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 |\n| [`@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 |\n| [`@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 |\n\n## The problem\n\nMobile browsers have two haptics paths, and both have friction:\n\n- **Android**: `navigator.vibrate()` works, but every component needs to call it manually and there's no pattern abstraction\n- **iOS**: Safari never implemented the Vibration API. The only web haptics path is the `\u003cinput type=\"checkbox\" switch\u003e` trick — but React 18's concurrent scheduler breaks the native gesture chain required for it to fire\n\n## How this works\n\nOn iOS, the library injects an invisible `\u003cinput type=\"checkbox\" switch\u003e` 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.\n\nOn Android, the standard Vibration API is used with full pattern support.\n\nA `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.\n\n## Install\n\n```bash\nnpm install @haptics/react    # React\nnpm install @haptics/vue      # Vue 3\nnpm install @haptics/svelte   # Svelte 5\nnpm install @haptics/vanilla  # No framework\nnpm install @haptics/core     # Engine only\n```\n\n\u003e 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.\n\n## Usage\n\nWrap your app with `HapticsProvider`:\n\n```tsx\nimport { HapticsProvider } from \"@haptics/react\";\n\nexport default function App({ children }) {\n  return \u003cHapticsProvider\u003e{children}\u003c/HapticsProvider\u003e;\n}\n```\n\nAdd `data-haptic` attributes to interactive elements:\n\n```tsx\n\u003cbutton data-haptic=\"success\"\u003eSubmit\u003c/button\u003e\n\u003cbutton data-haptic=\"impact-heavy\"\u003eDelete\u003c/button\u003e\n\u003ca data-haptic=\"selection\" href=\"/settings\"\u003eSettings\u003c/a\u003e\n```\n\nOr trigger imperatively via the hook:\n\n```tsx\nimport { useHaptics } from \"@haptics/react\";\n\nfunction SaveButton() {\n  const { trigger } = useHaptics();\n\n  const handleSave = async () =\u003e {\n    const ok = await save();\n    trigger(ok ? \"success\" : \"error\");\n  };\n\n  return (\n    \u003cbutton data-haptic=\"impact-medium\" onClick={handleSave}\u003e\n      Save\n    \u003c/button\u003e\n  );\n}\n```\n\n### Vue\n\n```ts\nimport { HapticsPlugin } from \"@haptics/vue\";\n\napp.use(HapticsPlugin);\n```\n\n```vue\n\u003cbutton v-haptic=\"'success'\"\u003eSave\u003c/button\u003e\n```\n\nOr use the composable:\n\n```ts\nconst { trigger } = useHaptics();\ntrigger(\"success\");\n```\n\n### Svelte\n\n```svelte\n\u003cscript\u003e\n  import { setupHaptics, haptic } from '@haptics/svelte';\n  setupHaptics();\n\u003c/script\u003e\n\n\u003cbutton use:haptic={'success'}\u003eSave\u003c/button\u003e\n```\n\n### Vanilla JS\n\n```ts\nimport { Haptics } from \"@haptics/vanilla\";\n\nconst haptics = new Haptics();\n// Any \u003cbutton data-haptic=\"success\"\u003e now triggers haptics on click\n// Or imperatively: haptics.trigger(\"success\");\n```\n\n## Presets\n\n| Name | Feel | Use case |\n| --- | --- | --- |\n| `selection` | Light tick | Toggles, minor state changes |\n| `impact-light` | Subtle tap | Gentle acknowledgment |\n| `impact-medium` | Standard tap | Button presses, navigation |\n| `impact-heavy` | Strong tap | Destructive actions, confirmations |\n| `success` | Rising confirmation | Form submit, save complete |\n| `warning` | Attention pulse | Validation warning |\n| `error` | Sharp rejection | Failed action, critical error |\n\n## Custom patterns\n\n```tsx\nimport { HapticsProvider } from \"@haptics/react\";\n\nconst patterns = {\n  \"card-tap\": [\n    { duration: 12, intensity: 0.5 },\n    { delay: 20, duration: 12, intensity: 0.5 },\n    { delay: 20, duration: 12, intensity: 0.5 },\n  ],\n};\n\n\u003cHapticsProvider patterns={patterns}\u003e\n  \u003cbutton data-haptic=\"card-tap\"\u003eTap me\u003c/button\u003e\n\u003c/HapticsProvider\u003e;\n```\n\nCustom patterns are merged with built-in presets. Same-name customs override the preset.\n\n## Configuration\n\n| Prop | Type | Default | Description |\n| --- | --- | --- | --- |\n| `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. |\n| `patterns` | `Record\u003cstring, HapticPattern\u003e` | `{}` | Custom patterns merged with built-in presets |\n\n## Platform support\n\n| Platform | Mechanism | Notes |\n| --- | --- | --- |\n| iOS Safari 17.4 – 26.4 | Switch overlay + programmatic re-tick | Full multi-tick patterns. Requires system haptics enabled. |\n| 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. |\n| Android Chrome / Edge | `navigator.vibrate()` | Full pattern support with timing sequences. |\n| Samsung Internet | `navigator.vibrate()` | Full pattern support. |\n| Firefox Android | Not supported | Vibration API removed in Firefox 129 (Aug 2024). |\n| Desktop | No-op | No haptic hardware. All calls resolve silently. |\n\n## API\n\n### `\u003cHapticsProvider\u003e`\n\nWraps your app. Registers a capture-phase click listener for iOS haptics. Without it, `data-haptic` attributes won't fire on iOS.\n\n### `useHaptics()`\n\nReturns:\n\n- `trigger(action)` — fire a haptic pattern by name (preset or custom)\n- `cancel()` — stop active vibration (Android only)\n- `isSupported` — `true` if the Vibration API is available (Android/Chrome)\n- `isIOSSupported` — `true` if iOS haptics are available\n\nWorks with or without HapticsProvider — falls back to built-in presets.\n\n### Core engine\n\nFor framework-agnostic or custom integrations:\n\n```ts\nimport {\n  isIOS,\n  isVibrationSupported,\n  iosTick,\n  schedulePattern,\n  toVibrateSequence,\n  PRESETS,\n} from \"@haptics/core\";\n```\n\n## Bundle size\n\nSizes measured after minification + gzip (level 9) — what a production bundler will actually ship.\n\n| Package | ESM (min + gz) | CJS (min + gz) |\n| --- | --- | --- |\n| `@haptics/core` | 1.85 KB | 1.88 KB |\n| `@haptics/react` | 0.67 KB | 0.77 KB |\n| `@haptics/vue` | 0.67 KB | 0.77 KB |\n| `@haptics/svelte` | 0.61 KB | 0.71 KB |\n| `@haptics/vanilla` | 0.58 KB | 0.69 KB |\n\nFramework 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).\n\n## Limitations\n\n**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.\n\n**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).\n\n**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.\n\n**HTML validity of `\u003cbutton data-haptic\u003e`**: the iOS overlay is appended as a child of `[data-haptic]` elements. The HTML spec's `\u003cbutton\u003e` 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 `\u003cinput\u003e` descendants of `\u003cbutton\u003e`.\n\n**Desktop**: All calls are silent no-ops. No haptic hardware exists on desktop browsers.\n\n**System haptics**: iOS haptics require the user's system haptics setting to be enabled (Settings \u003e Sounds \u0026 Haptics \u003e System Haptics).\n\n**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.\n\n**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.\n\n**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.\n\n**`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.\n\n**`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.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhowdoiusekeyboard%2Fhaptics","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhowdoiusekeyboard%2Fhaptics","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhowdoiusekeyboard%2Fhaptics/lists"}