
Web Haptics
- 719 installs
- 2.7k repo stars
- Updated March 9, 2026
- lochie/web-haptics
web-haptics is a frontend skill that adds tactile haptic feedback to mobile web interfaces for developers who want buttons, forms, toggles, and pickers to feel responsive on touch devices.
About
web-haptics is a frontend skill at version 1.0 from lochie/web-haptics for integrating the MIT-licensed web-haptics npm package into mobile-facing web apps. Install with npm i web-haptics; the library wraps the Web Vibration API with zero dependencies and silently no-ops on unsupported desktop browsers without requiring feature-detection boilerplate. The trigger() API accepts predefined haptic type strings for buttons, toggles, forms, and pickers. Developers reach for web-haptics when progressive web or responsive sites need native-feeling tap feedback on iOS and Android browsers where subtle vibration confirms successful interactions.
- Adds haptic feedback using the Web Vibration API with zero dependencies
- Silently no-ops on desktop and unsupported platforms
- Supports 9 distinct trigger types across notification, impact, and selection categories
- One-line React hook integration with useWebHaptics()
- MIT licensed, no error handling or feature detection required
Web Haptics by the numbers
- 719 all-time installs (skills.sh)
- +16 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #476 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lochie/web-haptics --skill web-hapticsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 719 |
|---|---|
| repo stars | ★ 2.7k |
| Security audit | 3 / 3 scanners passed |
| Last updated | March 9, 2026 |
| Repository | lochie/web-haptics ↗ |
How do you add haptic feedback to mobile web apps?
Add realistic tactile feedback to mobile-facing web interfaces so users feel confident when tapping buttons, submitting forms, or changing settings.
Who is it for?
Frontend developers building mobile-first or PWA interfaces that need tactile confirmation on taps and toggles.
Skip if: Desktop-only applications where vibration hardware is unavailable and haptic feedback provides no user benefit.
When should I use this skill?
A mobile-facing web UI needs tactile feedback on buttons, toggles, forms, or pickers during implementation.
What you get
Integrated web-haptics trigger calls on interactive UI elements with Web Vibration API feedback on supported devices.
- Haptic-enabled UI components
- trigger() integration on interactive elements
By the numbers
- web-haptics npm package ships with zero dependencies
- Skill metadata version 1.0
Files
Install web-haptics (npm i web-haptics) and add haptic feedback to the app following these rules:
Package: web-haptics
Repository: https://github.com/lochie/web-haptics | License: MIT | Zero dependencies Uses the Web Vibration API. Silently no-ops on unsupported platforms (desktop). No error handling or feature detection needed.
Trigger Types
trigger() accepts one of these strings (empty value defaults to a sensible "medium" impact):
Notification (task outcomes):
- "success" -- form saved, payment confirmed, upload complete
- "warning" -- destructive action ahead, approaching limit, irreversible step
- "error" -- validation failure, network error, permission denied
Impact (physical collisions):
- "light" -- small toggle, subtle tap, minor interaction
- "medium" -- button press, card snap-to-position, drop into place
- "heavy" -- major state change, heavy element landed, force press
Selection (discrete stepping):
- "selection" -- picker scroll, stepper increment, slider detent, segment switch
Framework Imports
React:
import { useWebHaptics } from "web-haptics/react";
const haptic = useWebHaptics();
<button onClick={() => haptic.trigger()}>Tap me</button>;Vue:
<script setup>
import { useWebHaptics } from "web-haptics/vue";
const haptic = useWebHaptics();
</script>
<template>
<button @click="haptic.trigger()">Tap me</button>
</template>Svelte:
<script>
import { createWebHaptics } from "web-haptics/svelte";
import { onDestroy } from "svelte";
const haptic = createWebHaptics();
onDestroy(() => haptic.destroy());
</script>
<button on:click={() => haptic.trigger()}>Tap me</button>Vanilla JS:
import { WebHaptics } from "web-haptics";
const haptics = new WebHaptics();
haptics.trigger(); // medium impact
haptics.trigger("success");Next.js: add "use client" to any component using useWebHaptics(). Nuxt/SvelteKit: works directly, library handles SSR.
Trigger Presets & defaultPatterns
All named string presets have a corresponding object in defaultPatterns. Use when you need to pass a preset as a value rather than a string literal:
import { WebHaptics, defaultPatterns } from 'web-haptics'
const haptics = new WebHaptics()
haptics.trigger(defaultPatterns.light)Extra presets not listed above: "soft", "rigid", "nudge", "buzz". See defaultPatterns for all available values.
Apple HIG Design Rules -- FOLLOW THESE
1. Haptics supplement, never replace. Always pair with visual feedback. UI must work fully without haptics. 2. Build causal relationships. The haptic must feel like a direct physical consequence of the user action. 3. Match intensity to significance. Light interactions = light/selection. Standard = medium/success. Major = heavy/error/warning. 4. Do not overuse. If every tap vibrates, nothing feels special. Reserve for meaningful moments only. 5. Synchronize perfectly. Fire haptic at the exact instant the visual change occurs. 6. Respect conventions. success=positive, error=negative, warning=cautionary, selection=discrete ticks only. 7. For async ops, trigger when the RESULT arrives, synced with the visual state change:
try {
await submit();
haptic.trigger("success");
} catch {
haptic.trigger("error");
}Interaction to Type Quick Reference
Primary button tap = "medium" | Secondary button = "light" Form success = "success" | Validation error = "error" | Network error = "error" Toggle switch = "light" | Delete before confirm = "warning" Picker/wheel = "selection" | Slider detents = "selection" | Tab switch = "selection" Drag-drop snap = "medium" | Long press = "heavy" | Modal appear = "medium" Pull-to-refresh threshold = "light" | Swipe dismiss threshold = "light" Payment confirmed = "success"
Anti-Patterns -- AVOID
- Haptic on every tap (fatigue)
- "error" for non-errors (breaks conventions)
- Haptic without visual feedback (some devices cannot vibrate)
- Haptic on page load or passive scroll (invasive)
- "heavy" for minor interactions (jarring)
- Long continuous vibrations (web haptics = brief transient pulses)
Before implementing, confirm with the user:
- Which interactive elements to target
- Whether to add at shared component level or individual call sites
{
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
Changesets
Hello and welcome! This folder has been automatically generated by @changesets/cli, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it in our repository
We have a quick list of common questions to get you started engaging with this project in our documentation
# Auto detect text files and perform LF normalization
* text=auto
# These are supported funding model platforms
github: lochie
.DS_Store
node_modules
apps/*/dist
react-example
0.0.6
Patch Changes
- Updated dependencies [91cefb2]
- Updated dependencies
- web-haptics@0.0.6
0.0.5
Patch Changes
- Updated dependencies
- web-haptics@0.0.5
0.0.4
Patch Changes
- Updated dependencies
- web-haptics@0.0.4
0.0.3
Patch Changes
- Updated dependencies
- web-haptics@0.0.3
0.0.2
Patch Changes
- Updated dependencies
- web-haptics@0.0.2
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebHaptics React Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "react-example",
"private": true,
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"web-haptics": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"typescript": "^5.0.2",
"vite": "^7.3.1"
}
}
import { useWebHaptics } from "web-haptics/react";
export default function App() {
const { trigger } = useWebHaptics();
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontSize: "2rem",
}}
>
<button onClick={() => trigger()}>Tap me</button>
</div>
);
}
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});
svelte-example
0.0.6
Patch Changes
- Updated dependencies [91cefb2]
- Updated dependencies
- web-haptics@0.0.6
0.0.5
Patch Changes
- Updated dependencies
- web-haptics@0.0.5
0.0.4
Patch Changes
- Updated dependencies
- web-haptics@0.0.4
0.0.3
Patch Changes
- Updated dependencies
- web-haptics@0.0.3
0.0.2
Patch Changes
- Updated dependencies
- web-haptics@0.0.2
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebHaptics Svelte Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "svelte-example",
"private": true,
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"web-haptics": "workspace:*"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0",
"typescript": "^5.0.2",
"vite": "^7.3.1"
}
}
<script lang="ts">
import { onDestroy } from 'svelte';
import { createWebHaptics } from 'web-haptics/svelte';
const { trigger, destroy } = createWebHaptics();
onDestroy(destroy);
</script>
<div class="container">
<button on:click={() => trigger()}>Tap me</button>
</div>
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 2rem;
}
</style>
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app")!,
});
export default app;
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import path from "path";
export default defineConfig({
plugins: [svelte()],
resolve: {
alias: {
"web-haptics/svelte": path.resolve(
__dirname,
"../../packages/web-haptics/src/svelte",
),
},
},
});
typescript-example
0.0.6
Patch Changes
- Updated dependencies [91cefb2]
- Updated dependencies
- web-haptics@0.0.6
0.0.5
Patch Changes
- Updated dependencies
- web-haptics@0.0.5
0.0.4
Patch Changes
- Updated dependencies
- web-haptics@0.0.4
0.0.3
Patch Changes
- Updated dependencies
- web-haptics@0.0.3
0.0.2
Patch Changes
- Updated dependencies
- web-haptics@0.0.2
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebHaptics TypeScript Example</title>
<style>
#app {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 2rem;
}
</style>
</head>
<body>
<div id="app">
<button id="trigger-btn">Tap me</button>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "typescript-example",
"private": true,
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"web-haptics": "workspace:*"
},
"devDependencies": {
"typescript": "^5.0.2",
"vite": "^7.3.1"
}
}
import { WebHaptics } from "web-haptics";
const haptics = new WebHaptics();
const button = document.getElementById("trigger-btn")!;
button.addEventListener("click", () => haptics.trigger());
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
import { defineConfig } from 'vite'
export default defineConfig({})
vue-example
0.0.6
Patch Changes
- Updated dependencies [91cefb2]
- Updated dependencies
- web-haptics@0.0.6
0.0.5
Patch Changes
- Updated dependencies
- web-haptics@0.0.5
0.0.4
Patch Changes
- Updated dependencies
- web-haptics@0.0.4
0.0.3
Patch Changes
- Updated dependencies
- web-haptics@0.0.3
0.0.2
Patch Changes
- Updated dependencies
- web-haptics@0.0.2
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebHaptics Vue Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "vue-example",
"private": true,
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.0",
"web-haptics": "workspace:*"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"typescript": "^5.0.2",
"vite": "^7.3.1"
}
}
<template>
<div class="container">
<button @click="trigger()">Tap me</button>
</div>
</template>
<script setup lang="ts">
import { useWebHaptics } from "web-haptics/vue";
const { trigger } = useWebHaptics();
</script>
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 2rem;
}
</style>
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"web-haptics/vue": path.resolve(__dirname, "../../packages/web-haptics/src/vue"),
},
},
});
module.exports = {extends: ['@commitlint/config-conventional']}
MIT License
Copyright (c) 2025 Lochie Axon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"private": true,
"type": "module",
"scripts": {
"install:all": "pnpm -r install",
"watch": "pnpm --filter=web-haptics dev",
"dev": "pnpm run watch --onSuccess \"pnpm -w run site:dev\"",
"build": "pnpm --filter=web-haptics build",
"site:dev": "pnpm --filter=site dev",
"site:build": "pnpm run build && pnpm --filter=site build",
"lint": "pnpm --filter=web-haptics lint",
"lint:fix": "pnpm --filter=web-haptics lint:fix",
"pre-commit": "pnpm run --filter=web-haptics pre-commit",
"version": "changeset version && git add --all",
"release": "pnpm build && changeset publish && git push --follow-tags"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@commitlint/cli": "^17.8.0",
"@commitlint/config-conventional": "^17.8.0",
"lint-staged": "^15.0.2"
}
}
node_modules
dist/**module.exports = {
root: true,
env: {
browser: true,
es2021: true,
},
extends: [
"airbnb",
"airbnb-typescript",
"airbnb/hooks",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
},
plugins: ["@typescript-eslint", "react", "prettier"],
settings: {
react: {
version: "detect",
},
},
rules: {
"react/require-default-props": "off",
"react/function-component-definition": [
2,
{
namedComponents: ["function-declaration", "arrow-function"],
unnamedComponents: "function-expression",
},
],
"react/react-in-jsx-scope": "off",
"no-console": "warn",
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/self-closing-comp": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: "after-used",
ignoreRestSiblings: false,
argsIgnorePattern: "^_.*?$",
},
],
},
};
dist
node_modulesmodule.exports = {
"src/**/*.{ts,tsx}": ["prettier --write", "eslint --fix", "eslint"],
};
dist
node_modulesmodule.exports = {
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
htmlWhitespaceSensitivity: "ignore",
};
web-haptics
0.0.6
Patch Changes
- 91cefb2: tree-shaking
- updated presets to match HIG
0.0.5
Patch Changes
- update package readme
0.0.4
Patch Changes
- fix: initial firing of haptic
0.0.3
Patch Changes
- better vibration API
0.0.2
Patch Changes
- change haptic name. add more debug modes. fixed double trigger bug
MIT License
Copyright (c) 2025 Lochie Axon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "web-haptics",
"version": "0.0.6",
"description": "Haptic feedback for the mobile web.",
"homepage": "https://haptics.lochie.me",
"sideEffects": false,
"author": "Lochie Axon",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/lochie/web-haptics.git",
"directory": "packages/web-haptics"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.mjs",
"require": "./dist/react/index.js"
},
"./vue": {
"types": "./dist/vue/index.d.ts",
"import": "./dist/vue/index.mjs",
"require": "./dist/vue/index.js"
},
"./svelte": {
"types": "./dist/svelte/index.d.ts",
"import": "./dist/svelte/index.mjs",
"require": "./dist/svelte/index.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint -c .eslintrc.cjs ./src/**/*.{ts,tsx}",
"lint:fix": "eslint --fix -c .eslintrc.cjs ./src/**/*.{ts,tsx}",
"pre-commit": "lint-staged",
"prepublishOnly": "pnpm build"
},
"keywords": [
"haptics",
"vibration",
"feedback",
"web",
"mobile",
"react",
"vue",
"svelte",
"typescript"
],
"bugs": {
"url": "https://github.com/lochie/web-haptics/issues"
},
"files": [
"dist/**/*.js",
"dist/**/*.mjs",
"dist/**/*.d.ts",
"dist/**/*.d.mts",
"LICENSE",
"README.md"
],
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"vue": ">=3",
"svelte": ">=4"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"vue": {
"optional": true
},
"svelte": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"eslint": "^9.36.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"svelte": "^4.0.0",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"vue": "^3.3.0"
}
}
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
devDependencies:
'@types/react':
specifier: ^18.2.15
version: 18.2.27
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
tsup:
specifier: ^7.2.0
version: 7.2.0(typescript@5.2.2)
typescript:
specifier: ^5.0.2
version: 5.2.2
packages:
/@esbuild/android-arm64@0.18.20:
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.18.20:
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.18.20:
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.18.20:
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.18.20:
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.18.20:
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.18.20:
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.18.20:
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.18.20:
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.18.20:
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.18.20:
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.18.20:
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.18.20:
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.18.20:
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.18.20:
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.18.20:
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.18.20:
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.18.20:
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.18.20:
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.18.20:
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.18.20:
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.18.20:
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@jridgewell/gen-mapping@0.3.3:
resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==}
engines: {node: '>=6.0.0'}
dependencies:
'@jridgewell/set-array': 1.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@jridgewell/trace-mapping': 0.3.19
dev: true
/@jridgewell/resolve-uri@3.1.1:
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
dev: true
/@jridgewell/set-array@1.1.2:
resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==}
engines: {node: '>=6.0.0'}
dev: true
/@jridgewell/sourcemap-codec@1.4.15:
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
dev: true
/@jridgewell/trace-mapping@0.3.19:
resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==}
dependencies:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
dev: true
/@nodelib/fs.stat@2.0.5:
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
dev: true
/@nodelib/fs.walk@1.2.8:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.15.0
dev: true
/@types/prop-types@15.7.8:
resolution: {integrity: sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==}
dev: true
/@types/react@18.2.27:
resolution: {integrity: sha512-Wfv7B7FZiR2r3MIqbAlXoY1+tXm4bOqfz4oRr+nyXdBqapDBZ0l/IGcSlAfvxIHEEJjkPU0MYAc/BlFPOcrgLw==}
dependencies:
'@types/prop-types': 15.7.8
'@types/scheduler': 0.16.4
csstype: 3.1.2
dev: true
/@types/scheduler@0.16.4:
resolution: {integrity: sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==}
dev: true
/any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
dev: true
/anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
dev: true
/array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
dev: true
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
dev: true
/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
dev: true
/braces@3.0.2:
resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
engines: {node: '>=8'}
dependencies:
fill-range: 7.0.1
dev: true
/bundle-require@4.0.2(esbuild@0.18.20):
resolution: {integrity: sha512-jwzPOChofl67PSTW2SGubV9HBQAhhR2i6nskiOThauo9dzwDUgOWQScFVaJkjEfYX+UXiD+LEx8EblQMc2wIag==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
esbuild: '>=0.17'
dependencies:
esbuild: 0.18.20
load-tsconfig: 0.2.5
dev: true
/cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
dev: true
/chokidar@3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
engines: {node: '>= 8.10.0'}
dependencies:
anymatch: 3.1.3
braces: 3.0.2
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
dev: true
/commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
dev: true
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
dev: true
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: true
/debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.2
dev: true
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dependencies:
path-type: 4.0.0
dev: true
/esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.18.20
'@esbuild/android-arm64': 0.18.20
'@esbuild/android-x64': 0.18.20
'@esbuild/darwin-arm64': 0.18.20
'@esbuild/darwin-x64': 0.18.20
'@esbuild/freebsd-arm64': 0.18.20
'@esbuild/freebsd-x64': 0.18.20
'@esbuild/linux-arm': 0.18.20
'@esbuild/linux-arm64': 0.18.20
'@esbuild/linux-ia32': 0.18.20
'@esbuild/linux-loong64': 0.18.20
'@esbuild/linux-mips64el': 0.18.20
'@esbuild/linux-ppc64': 0.18.20
'@esbuild/linux-riscv64': 0.18.20
'@esbuild/linux-s390x': 0.18.20
'@esbuild/linux-x64': 0.18.20
'@esbuild/netbsd-x64': 0.18.20
'@esbuild/openbsd-x64': 0.18.20
'@esbuild/sunos-x64': 0.18.20
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
dev: true
/execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
dependencies:
cross-spawn: 7.0.3
get-stream: 6.0.1
human-signals: 2.1.0
is-stream: 2.0.1
merge-stream: 2.0.0
npm-run-path: 4.0.1
onetime: 5.1.2
signal-exit: 3.0.7
strip-final-newline: 2.0.0
dev: true
/fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: true
/fastq@1.15.0:
resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
dependencies:
reusify: 1.0.4
dev: true
/fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
dependencies:
to-regex-range: 5.0.1
dev: true
/fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
dev: true
/glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
dependencies:
is-glob: 4.0.3
dev: true
/glob@7.1.6:
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: true
/globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
fast-glob: 3.3.1
ignore: 5.2.4
merge2: 1.4.1
slash: 3.0.0
dev: true
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
dev: true
/ignore@5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}
dev: true
/inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
dependencies:
once: 1.4.0
wrappy: 1.0.2
dev: true
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: true
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
dependencies:
binary-extensions: 2.2.0
dev: true
/is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
dev: true
/is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
dependencies:
is-extglob: 2.1.1
dev: true
/is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
dev: true
/is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
dev: true
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
dev: true
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: true
/lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
dev: true
/lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: true
/load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
/lodash.sortby@4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
dev: true
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
dependencies:
js-tokens: 4.0.0
dev: true
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
dev: true
/micromatch@4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
engines: {node: '>=8.6'}
dependencies:
braces: 3.0.2
picomatch: 2.3.1
dev: true
/mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
dev: true
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
brace-expansion: 1.1.11
dev: true
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
dev: true
/normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
dev: true
/npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
dependencies:
path-key: 3.1.1
dev: true
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
dev: true
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
wrappy: 1.0.2
dev: true
/onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
dependencies:
mimic-fn: 2.1.0
dev: true
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
dev: true
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
dev: true
/path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
dev: true
/picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
dev: true
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
dev: true
/postcss-load-config@4.0.1:
resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
engines: {node: '>= 14'}
peerDependencies:
postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 2.1.0
yaml: 2.3.2
dev: true
/punycode@2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
engines: {node: '>=6'}
dev: true
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/react-dom@18.2.0(react@18.2.0):
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
react: ^18.2.0
dependencies:
loose-envify: 1.4.0
react: 18.2.0
scheduler: 0.23.0
dev: true
/react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
dev: true
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
picomatch: 2.3.1
dev: true
/resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
dev: true
/reusify@1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
/rollup@3.29.4:
resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.3
dev: true
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
queue-microtask: 1.2.3
dev: true
/scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
loose-envify: 1.4.0
dev: true
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
dev: true
/shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
dev: true
/signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
dev: true
/slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
dev: true
/source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
dependencies:
whatwg-url: 7.1.0
dev: true
/strip-final-newline@2.0.0:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
dev: true
/sucrase@3.34.0:
resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==}
engines: {node: '>=8'}
hasBin: true
dependencies:
'@jridgewell/gen-mapping': 0.3.3
commander: 4.1.1
glob: 7.1.6
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.6
ts-interface-checker: 0.1.13
dev: true
/thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
dependencies:
thenify: 3.3.1
dev: true
/thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
dependencies:
any-promise: 1.3.0
dev: true
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
dependencies:
is-number: 7.0.0
dev: true
/tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
dependencies:
punycode: 2.3.0
dev: true
/tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
dev: true
/ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
dev: true
/tsup@7.2.0(typescript@5.2.2):
resolution: {integrity: sha512-vDHlczXbgUvY3rWvqFEbSqmC1L7woozbzngMqTtL2PGBODTtWlRwGDDawhvWzr5c1QjKe4OAKqJGfE1xeXUvtQ==}
engines: {node: '>=16.14'}
hasBin: true
peerDependencies:
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.1.0'
peerDependenciesMeta:
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
dependencies:
bundle-require: 4.0.2(esbuild@0.18.20)
cac: 6.7.14
chokidar: 3.5.3
debug: 4.3.4
esbuild: 0.18.20
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss-load-config: 4.0.1
resolve-from: 5.0.0
rollup: 3.29.4
source-map: 0.8.0-beta.0
sucrase: 3.34.0
tree-kill: 1.2.2
typescript: 5.2.2
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
dev: true
/whatwg-url@7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
dependencies:
lodash.sortby: 4.7.0
tr46: 1.0.1
webidl-conversions: 4.0.2
dev: true
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0
dev: true
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/yaml@2.3.2:
resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==}
engines: {node: '>= 14'}
dev: true
WebHaptics
Haptic feedback for the mobile web. Supports React, Vue, and Svelte.
Install
npm i web-hapticsReact
import { useWebHaptics } from "web-haptics/react";
function App() {
const { trigger } = useWebHaptics();
return <button onClick={() => trigger("success")}>Tap me</button>;
}Vue
<script setup>
import { useWebHaptics } from "web-haptics/vue";
const { trigger } = useWebHaptics();
</script>
<template>
<button @click="trigger('success')">Tap me</button>
</template>Svelte
<script>
import { createWebHaptics } from "web-haptics/svelte";
import { onDestroy } from "svelte";
const { trigger, destroy } = createWebHaptics();
onDestroy(destroy);
</script>
<button on:click={() => trigger("success")}>Tap me</button>Vanilla
import { WebHaptics } from "web-haptics";
const haptics = new WebHaptics();
haptics.trigger("success");Built-in Presets
| Name | Pattern | Description |
|---|---|---|
success | [{ duration: 50 }, { delay: 50, duration: 50 }] | Two taps indicating success |
nudge | [{ duration: 80, intensity: 0.8 }, { delay: 80, duration: 50, intensity: 0.3 }] | Strong tap followed by a soft tap |
error | [{ duration: 50, intensity: 0.75 }, ...] ×3 | Three sharp taps for errors |
buzz | [{ duration: 1000, intensity: 1 }] | A long vibration |
You can also pass custom patterns directly:
trigger([100, 50, 100]); // number[] shorthand (alternating on/off durations)
trigger(200); // single vibration (ms)
trigger([{ duration: 80, intensity: 0.8 }, { delay: 50, duration: 100 }]); // Vibration[]
trigger({
pattern: [{ duration: 50 }, { delay: 50, duration: 50 }],
description: "custom",
}); // full HapticPresetAPI
new WebHaptics(options?)
Create a new instance.
options.debug— enable audio feedback for testing on desktop (defaultfalse)options.showSwitch— show the haptic feedback toggle switch (defaultfalse)
trigger(input?, options?): Promise<void>
Trigger haptic feedback.
input— preset name ("success"), duration in ms,number[],Vibration[], orHapticPresetoptions.intensity— override default intensity (0–1, default0.5)
cancel()
Stop the current pattern and cancel any ongoing vibration.
destroy()
Clean up DOM elements and audio resources. Call when the instance is no longer needed.
setDebug(debug: boolean)
Enable or disable debug audio feedback.
setShowSwitch(show: boolean)
Show or hide the haptic feedback toggle switch.
WebHaptics.isSupported
Static boolean — true if the device supports the Vibration API.
License
MIT
Found this useful?
Follow me on Twitter.
Other projects
You might also like:
- Torph - Dependency-free animated text component.
- easing.dev - Easily create custom easing graphs.
Acknowledgements
- Special thanks to Alex for assistance with the site design.
export { version } from "./../package.json";
export { WebHaptics } from "./lib/web-haptics";
export { defaultPatterns } from "./lib/web-haptics/patterns";
export type {
Vibration,
HapticPattern,
HapticPreset,
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "./lib/web-haptics/types";
import { defaultPatterns } from "./patterns";
import type {
HapticInput,
TriggerOptions,
Vibration,
WebHapticsOptions,
} from "./types";
const TOGGLE_MIN = 16; // ms at intensity 1 (every frame)
const TOGGLE_MAX = 184; // range above min (0.5 intensity ≈ 100ms)
const MAX_PHASE_MS = 1000; // browser haptic window limit
const PWM_CYCLE = 20; // ms per intensity modulation cycle
/** Convert any HapticInput into a Vibration array. */
function normalizeInput(input: HapticInput): {
vibrations: Vibration[];
} | null {
if (typeof input === "number") {
return { vibrations: [{ duration: input }] };
}
if (typeof input === "string") {
const preset = defaultPatterns[input as keyof typeof defaultPatterns];
if (!preset) {
console.warn(`[web-haptics] Unknown preset: "${input}"`);
return null;
}
return { vibrations: preset.pattern.map((v) => ({ ...v })) };
}
if (Array.isArray(input)) {
if (input.length === 0) return { vibrations: [] };
// number[] shorthand — alternating on/off
if (typeof input[0] === "number") {
const nums = input as number[];
const vibrations: Vibration[] = [];
for (let i = 0; i < nums.length; i += 2) {
const delay = i > 0 ? nums[i - 1]! : 0;
vibrations.push({
...(delay > 0 && { delay }),
duration: nums[i]!,
});
}
return { vibrations };
}
// Vibration[]
return { vibrations: (input as Vibration[]).map((v) => ({ ...v })) };
}
// HapticPreset
return { vibrations: input.pattern.map((v) => ({ ...v })) };
}
/**
* Apply PWM modulation to a single vibration duration at a given intensity.
* Returns the flat on/off segments for this vibration.
*/
function modulateVibration(duration: number, intensity: number): number[] {
if (intensity >= 1) return [duration];
if (intensity <= 0) return [];
const onTime = Math.max(1, Math.round(PWM_CYCLE * intensity));
const offTime = PWM_CYCLE - onTime;
const result: number[] = [];
let remaining = duration;
while (remaining >= PWM_CYCLE) {
result.push(onTime);
result.push(offTime);
remaining -= PWM_CYCLE;
}
if (remaining > 0) {
const remOn = Math.max(1, Math.round(remaining * intensity));
result.push(remOn);
const remOff = remaining - remOn;
if (remOff > 0) result.push(remOff);
}
return result;
}
/**
* Convert Vibration[] to the flat number[] pattern for navigator.vibrate(),
* applying per-vibration PWM intensity modulation.
*/
function toVibratePattern(
vibrations: Vibration[],
defaultIntensity: number,
): number[] {
const result: number[] = [];
for (let i = 0; i < vibrations.length; i++) {
const vib = vibrations[i]!;
const intensity = Math.max(
0,
Math.min(1, vib.intensity ?? defaultIntensity),
);
const delay = vib.delay ?? 0;
// Prepend delay: merge into trailing off-time or add new gap
if (delay > 0) {
if (result.length > 0 && result.length % 2 === 0) {
result[result.length - 1]! += delay;
} else {
if (result.length === 0) result.push(0);
result.push(delay);
}
}
const modulated = modulateVibration(vib.duration, intensity);
if (modulated.length === 0) {
// Zero intensity — treat vibration as silence
if (result.length > 0 && result.length % 2 === 0) {
result[result.length - 1]! += vib.duration;
} else if (vib.duration > 0) {
result.push(0);
result.push(vib.duration);
}
continue;
}
// Append modulated vibration segments
for (const seg of modulated) {
result.push(seg);
}
}
return result;
}
let instanceCounter = 0;
export class WebHaptics {
private hapticLabel: HTMLLabelElement | null = null;
private domInitialized = false;
private instanceId: number;
private debug: boolean;
private showSwitch: boolean;
private rafId: number | null = null;
private patternResolve: (() => void) | null = null;
private audioCtx: AudioContext | null = null;
private audioFilter: BiquadFilterNode | null = null;
private audioGain: GainNode | null = null;
private audioBuffer: AudioBuffer | null = null;
constructor(options?: WebHapticsOptions) {
this.instanceId = ++instanceCounter;
this.debug = options?.debug ?? false;
this.showSwitch = options?.showSwitch ?? false;
}
static readonly isSupported: boolean =
typeof navigator !== "undefined" && typeof navigator.vibrate === "function";
async trigger(
input: HapticInput = [{ duration: 25, intensity: 0.7 }],
options?: TriggerOptions,
): Promise<void> {
const normalized = normalizeInput(input);
if (!normalized) return;
const { vibrations } = normalized;
if (vibrations.length === 0) return;
const defaultIntensity = Math.max(
0,
Math.min(1, options?.intensity ?? 0.5),
);
// Validate and clamp durations
for (const vib of vibrations) {
if (vib.duration > MAX_PHASE_MS) vib.duration = MAX_PHASE_MS;
if (
!Number.isFinite(vib.duration) ||
vib.duration < 0 ||
(vib.delay !== undefined &&
(!Number.isFinite(vib.delay) || vib.delay < 0))
) {
console.warn(
`[web-haptics] Invalid vibration values. Durations and delays must be finite non-negative numbers.`,
);
return;
}
}
if (WebHaptics.isSupported) {
navigator.vibrate(toVibratePattern(vibrations, defaultIntensity));
}
if (!WebHaptics.isSupported || this.debug) {
this.ensureDOM();
if (!this.hapticLabel) return;
if (this.debug) {
await this.ensureAudio();
}
this.stopPattern();
const firstDelay = vibrations[0]?.delay ?? 0;
const firstClickFired = firstDelay === 0;
// Fire first click synchronously to stay within user gesture context
// (only when the first vibration has no delay)
if (firstClickFired) {
this.hapticLabel.click();
if (this.debug && this.audioCtx) {
const firstIntensity = Math.max(
0,
Math.min(1, vibrations[0]!.intensity ?? defaultIntensity),
);
this.playClick(firstIntensity);
}
}
await this.runPattern(vibrations, defaultIntensity, firstClickFired);
}
}
cancel(): void {
this.stopPattern();
if (WebHaptics.isSupported) {
navigator.vibrate(0);
}
}
destroy(): void {
this.stopPattern();
if (this.hapticLabel) {
this.hapticLabel.remove();
this.hapticLabel = null;
this.domInitialized = false;
}
if (this.audioCtx) {
this.audioCtx.close();
this.audioCtx = null;
this.audioFilter = null;
this.audioGain = null;
this.audioBuffer = null;
}
}
setDebug(debug: boolean): void {
this.debug = debug;
if (!debug && this.audioCtx) {
this.audioCtx.close();
this.audioCtx = null;
this.audioFilter = null;
this.audioGain = null;
this.audioBuffer = null;
}
}
setShowSwitch(show: boolean): void {
this.showSwitch = show;
if (this.hapticLabel) {
const checkbox = this.hapticLabel.querySelector("input");
this.hapticLabel.style.display = show ? "" : "none";
if (checkbox) checkbox.style.display = show ? "" : "none";
}
}
private stopPattern(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.patternResolve?.();
this.patternResolve = null;
}
private runPattern(
vibrations: Vibration[],
defaultIntensity: number,
firstClickFired: boolean,
): Promise<void> {
return new Promise((resolve) => {
this.patternResolve = resolve;
// Build phase boundaries: each vibration has an optional delay then an "on" phase
const phases: { end: number; isOn: boolean; intensity: number }[] = [];
let cumulative = 0;
for (const vib of vibrations) {
const intensity = Math.max(
0,
Math.min(1, vib.intensity ?? defaultIntensity),
);
const delay = vib.delay ?? 0;
if (delay > 0) {
cumulative += delay;
phases.push({ end: cumulative, isOn: false, intensity: 0 });
}
cumulative += vib.duration;
phases.push({ end: cumulative, isOn: true, intensity });
}
const totalDuration = cumulative;
let startTime = 0;
let lastToggleTime = -1;
const loop = (time: number) => {
if (startTime === 0) startTime = time;
const elapsed = time - startTime;
if (elapsed >= totalDuration) {
this.rafId = null;
this.patternResolve = null;
resolve();
return;
}
// Find current phase
let phase = phases[0]!;
for (const p of phases) {
if (elapsed < p.end) {
phase = p;
break;
}
}
if (phase.isOn) {
const toggleInterval =
TOGGLE_MIN + (1 - phase.intensity) * TOGGLE_MAX;
if (lastToggleTime === -1) {
lastToggleTime = time;
if (!firstClickFired) {
this.hapticLabel?.click();
if (this.debug && this.audioCtx) {
this.playClick(phase.intensity);
}
firstClickFired = true;
}
} else if (time - lastToggleTime >= toggleInterval) {
this.hapticLabel?.click();
if (this.debug && this.audioCtx) {
this.playClick(phase.intensity);
}
lastToggleTime = time;
}
}
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
});
}
private playClick(intensity: number): void {
if (
!this.audioCtx ||
!this.audioFilter ||
!this.audioGain ||
!this.audioBuffer
)
return;
const data = this.audioBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 25);
}
this.audioGain.gain.value = 0.5 * intensity;
const baseFreq = 2000 + intensity * 2000;
const jitter = 1 + (Math.random() - 0.5) * 0.3;
this.audioFilter.frequency.value = baseFreq * jitter;
const source = this.audioCtx.createBufferSource();
source.buffer = this.audioBuffer;
source.connect(this.audioFilter);
source.onended = () => source.disconnect();
source.start();
}
private async ensureAudio(): Promise<void> {
if (!this.audioCtx && typeof AudioContext !== "undefined") {
this.audioCtx = new AudioContext();
this.audioFilter = this.audioCtx.createBiquadFilter();
this.audioFilter.type = "bandpass";
this.audioFilter.frequency.value = 4000;
this.audioFilter.Q.value = 8;
this.audioGain = this.audioCtx.createGain();
this.audioFilter.connect(this.audioGain);
this.audioGain.connect(this.audioCtx.destination);
const duration = 0.004;
this.audioBuffer = this.audioCtx.createBuffer(
1,
this.audioCtx.sampleRate * duration,
this.audioCtx.sampleRate,
);
const data = this.audioBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 25);
}
}
if (this.audioCtx?.state === "suspended") {
await this.audioCtx.resume();
}
}
private ensureDOM(): void {
if (this.domInitialized) return;
if (typeof document === "undefined") return;
const id = `web-haptics-${this.instanceId}`;
const hapticLabel = document.createElement("label");
hapticLabel.setAttribute("for", id);
hapticLabel.textContent = "Haptic feedback";
hapticLabel.style.position = "fixed";
hapticLabel.style.bottom = "10px";
hapticLabel.style.left = "10px";
hapticLabel.style.padding = "5px 10px";
hapticLabel.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
hapticLabel.style.color = "white";
hapticLabel.style.fontFamily = "sans-serif";
hapticLabel.style.fontSize = "14px";
hapticLabel.style.borderRadius = "4px";
hapticLabel.style.zIndex = "9999";
hapticLabel.style.userSelect = "none";
this.hapticLabel = hapticLabel;
const hapticCheckbox = document.createElement("input");
hapticCheckbox.type = "checkbox";
hapticCheckbox.setAttribute("switch", "");
hapticCheckbox.id = id;
hapticCheckbox.style.all = "initial";
hapticCheckbox.style.appearance = "auto";
if (!this.showSwitch) {
hapticLabel.style.display = "none";
hapticCheckbox.style.display = "none";
}
hapticLabel.appendChild(hapticCheckbox);
document.body.appendChild(hapticLabel);
this.domInitialized = true;
}
}
import type { HapticPreset } from "./types";
export const defaultPatterns = {
// --- Notification (UINotificationFeedbackGenerator) ---
success: {
// ascending double-tap indicating success.
pattern: [
{ duration: 30, intensity: 0.5 },
{ delay: 60, duration: 40, intensity: 1 },
],
},
warning: {
// two taps with hesitation indicating a warning.
pattern: [
{ duration: 40, intensity: 0.8 },
{ delay: 100, duration: 40, intensity: 0.6 },
],
},
error: {
// three rapid harsh taps indicating an error.
pattern: [
{ duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.9 },
{ delay: 40, duration: 50, intensity: 0.6 },
],
},
// --- Impact (UIImpactFeedbackGenerator) ---
light: {
// single light tap indicating a minor impact.
pattern: [{ duration: 15, intensity: 0.4 }],
},
medium: {
// moderate tap for standard interactions.
pattern: [{ duration: 25, intensity: 0.7 }],
},
heavy: {
// strong tap for significant interactions.
pattern: [{ duration: 35, intensity: 1 }],
},
soft: {
// soft, cushioned tap with a rounded feel.
pattern: [{ duration: 40, intensity: 0.5 }],
},
rigid: {
// hard, crisp tap with a precise feel.
pattern: [{ duration: 10, intensity: 1 }],
},
// --- Selection (UISelectionFeedbackGenerator) ---
selection: {
// subtle tap for selection changes.
pattern: [{ duration: 8, intensity: 0.3 }],
},
// --- Custom ---
nudge: {
// two quick taps with a pause, indicating a nudge or reminder.
pattern: [
{ duration: 80, intensity: 0.8 },
{ delay: 80, duration: 50, intensity: 0.3 },
],
},
buzz: {
// rapid, low-intensity taps creating a buzzing effect.
pattern: [{ duration: 1000, intensity: 1 }],
},
} as const satisfies Record<string, HapticPreset>;
export interface Vibration {
duration: number;
intensity?: number;
delay?: number;
}
export type HapticPattern = number[] | Vibration[];
export interface HapticPreset {
pattern: Vibration[];
}
export type HapticInput = number | string | HapticPattern | HapticPreset;
export interface TriggerOptions {
intensity?: number;
}
export interface WebHapticsOptions {
debug?: boolean;
showSwitch?: boolean;
}
export { useWebHaptics } from "./useWebHaptics";
"use client";
import { useRef, useEffect, useCallback } from "react";
import { WebHaptics } from "../lib/web-haptics";
import type {
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "../lib/web-haptics/types";
export function useWebHaptics(options?: WebHapticsOptions) {
const instanceRef = useRef<WebHaptics | null>(null);
useEffect(() => {
instanceRef.current = new WebHaptics(options);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, []);
useEffect(() => {
instanceRef.current?.setDebug(options?.debug ?? false);
}, [options?.debug]);
useEffect(() => {
instanceRef.current?.setShowSwitch(options?.showSwitch ?? false);
}, [options?.showSwitch]);
const trigger = useCallback(
(input?: HapticInput, options?: TriggerOptions) =>
instanceRef.current?.trigger(input, options),
[],
);
const cancel = useCallback(() => instanceRef.current?.cancel(), []);
const isSupported = WebHaptics.isSupported;
return { trigger, cancel, isSupported };
}
import { WebHaptics } from "../lib/web-haptics";
import type {
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "../lib/web-haptics/types";
export function createWebHaptics(options?: WebHapticsOptions) {
const instance = new WebHaptics(options);
const trigger = (input?: HapticInput, options?: TriggerOptions) =>
instance.trigger(input, options);
const cancel = () => instance.cancel();
const destroy = () => instance.destroy();
const setDebug = (debug: boolean) => instance.setDebug(debug);
const isSupported = WebHaptics.isSupported;
return { trigger, cancel, destroy, setDebug, isSupported };
}
export { createWebHaptics } from "./createWebHaptics";
export type {
Vibration,
HapticPattern,
HapticPreset,
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "../lib/web-haptics/types";
export { useWebHaptics } from "./useWebHaptics";
export type {
Vibration,
HapticPattern,
HapticPreset,
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "../lib/web-haptics/types";
import { onMounted, onUnmounted, watch } from "vue";
import { WebHaptics } from "../lib/web-haptics";
import type {
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "../lib/web-haptics/types";
export function useWebHaptics(options?: WebHapticsOptions) {
let instance: WebHaptics | null = null;
onMounted(() => {
instance = new WebHaptics(options);
});
onUnmounted(() => {
instance?.destroy();
instance = null;
});
watch(
() => options?.debug,
(val) => {
instance?.setDebug(val ?? false);
},
);
const trigger = (input?: HapticInput, options?: TriggerOptions) =>
instance?.trigger(input, options);
const cancel = () => instance?.cancel();
const isSupported = WebHaptics.isSupported;
return { trigger, cancel, isSupported };
}
{
"compilerOptions": {
"lib": ["DOM", "ES2022", "DOM.Iterable"],
"esModuleInterop": true,
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"moduleDetection": "force"
},
"include": [".eslintrc.cjs", "src/**/*.ts", "src/**/*.tsx"]
}
import { defineConfig, type Options } from "tsup";
const aliasCorePlugin = {
name: "alias-core",
setup(build: any) {
build.onResolve({ filter: /\.\.\/lib\/web-haptics/ }, () => ({
path: "web-haptics",
external: true,
}));
},
};
export default defineConfig((options) => {
const configs: Options[] = [
// Core
{
entry: {
index: "src/index.ts",
},
format: ["cjs", "esm"],
dts: true,
clean: true,
target: "es2022",
treeshake: true,
minify: !options.watch,
},
// React
{
entry: {
"react/index": "src/react/index.ts",
},
format: ["cjs", "esm"],
dts: true,
target: "es2022",
treeshake: true,
external: ["react", "react/jsx-runtime"],
esbuildPlugins: [aliasCorePlugin],
minify: !options.watch,
banner: { js: '"use client";' },
},
// Vue
{
entry: {
"vue/index": "src/vue/index.ts",
},
format: ["cjs", "esm"],
dts: true,
target: "es2022",
treeshake: true,
external: ["vue"],
esbuildPlugins: [aliasCorePlugin],
minify: !options.watch,
},
// Svelte
{
entry: {
"svelte/index": "src/svelte/index.ts",
},
format: ["cjs", "esm"],
dts: true,
target: "es2022",
treeshake: true,
external: ["svelte"],
esbuildPlugins: [aliasCorePlugin],
minify: !options.watch,
},
];
return configs;
});
packages:
- "packages/**"
- "site"
- "apps/**"
WebHaptics
Haptic feedback for the mobile web. Supports React, Vue, and Svelte.
Installation
npm i web-hapticsUsage
import { useWebHaptics } from "web-haptics/react";
function App() {
const { trigger } = useWebHaptics();
return <button onClick={() => trigger("success")}>Tap me</button>;
}See the package README for Vue, Svelte, and vanilla examples.
Contributing
Install dependencies
pnpm install:allDev/Watch Library and Example
pnpm devBuild Library
pnpm buildFound this useful?
Follow me on Twitter.
Other projects
You might also like:
- Torph - Dependency-free animated text component.
- easing.dev - Easily create custom easing graphs.
Acknowledgements
- Special thanks to Alex for assistance with the site design.
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
site
0.0.10
Patch Changes
- Updated dependencies [91cefb2]
- Updated dependencies
- web-haptics@0.0.6
0.0.9
Patch Changes
- Updated dependencies
- web-haptics@0.0.5
0.0.8
Patch Changes
- Updated dependencies
- web-haptics@0.0.4
0.0.7
Patch Changes
- Updated dependencies
- web-haptics@0.0.3
0.0.6
Patch Changes
- Updated dependencies
- web-haptics@0.0.2
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WebHaptics – Haptic feedback for the mobile web.</title>
<meta name="description" content="Haptic feedback for the mobile web." />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="WebHaptics" />
<meta
property="og:title"
content="WebHaptics – Haptic feedback for the mobile web."
/>
<meta property="og:image" content="https://haptics.lochie.me/og.png" />
<meta
property="og:description"
content="Haptic feedback for the mobile web."
/>
<meta name="twitter:site" content="@lochieaxon" />
<meta name="twitter:creator" content="@lochieaxon" />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
value="WebHaptics – Haptic feedback for the mobile web."
/>
<meta name="twitter:image" content="https://haptics.lochie.me/og.png" />
<meta
name="twitter:description"
value="Haptic feedback for the mobile web."
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🫨</text></svg>"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="msapplication-navbutton-color" content="#ffffff" />
<meta name="apple-mobile-web-app-status-bar-style" content="#ffffff" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "site",
"private": true,
"version": "0.0.10",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"motion": "^12.23.16",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"web-haptics": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8",
"torph": "^0.0.9",
"typescript": "^5.0.2",
"vite": "^6.4.1"
}
}
export default {
plugins: {
autoprefixer: {},
},
}
# web-haptics
> Haptic feedback for the web. Zero dependencies. Uses the Web Vibration API with framework hooks for React, Vue, and Svelte. Silently no-ops on unsupported platforms.
## Install
npm i web-haptics
## API
`trigger(preset?)` fires a haptic pattern. Called with no arguments, it defaults to a medium impact tap.
Preset strings:
- "success" — positive outcome (form saved, payment confirmed)
- "warning" — cautionary (destructive action ahead, approaching limit)
- "error" — negative outcome (validation failure, network error)
- "light" — subtle tap (small toggle, minor interaction)
- "medium" — standard tap (button press, snap-to-position)
- "heavy" — strong tap (major state change, force press)
- "selection" — discrete tick (picker scroll, slider detent, segment switch)
## Framework Usage
React:
```tsx
import { useWebHaptics } from "web-haptics/react";
const haptic = useWebHaptics();
haptic.trigger("success");
```
Vue:
```vue
import { useWebHaptics } from "web-haptics/vue";
const haptic = useWebHaptics();
haptic.trigger("success");
```
Svelte:
```svelte
import { createWebHaptics } from "web-haptics/svelte";
const haptic = createWebHaptics();
haptic.trigger("success");
```
Vanilla:
```js
import { WebHaptics } from "web-haptics";
const haptics = new WebHaptics();
haptics.trigger("success");
```
Next.js: add "use client" to components using useWebHaptics().
## Other Methods
- `cancel()` — stop current vibration
- `destroy()` — clean up resources
- `setDebug(bool)` — toggle audio feedback for desktop testing
- `WebHaptics.isSupported` — static property, check device support
## Links
- [GitHub](https://github.com/lochie/web-haptics)
- [npm](https://www.npmjs.com/package/web-haptics)
- [SKILL.md](https://github.com/lochie/web-haptics/blob/main/SKILL.md): Agent skill with full usage guidelines and Apple HIG design rules
import DesktopView from "./surfaces/desktop-view";
import MobileView from "./surfaces/mobile-view";
import { ParticlesProvider } from "./components/particles";
import { AppProvider } from "./context/app";
function App() {
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
return (
<AppProvider>
<ParticlesProvider>
{isMobile ? <MobileView /> : <DesktopView />}
</ParticlesProvider>
</AppProvider>
);
}
export default App;
import { useAutoResize } from "./../../hooks/useAutoResize";
export const AutoResize = ({
children,
overflow = false,
duration = 400,
className,
property = "height",
}: {
children: React.ReactNode;
overflow?: boolean;
duration?: number;
className?: string;
property?: "height" | "width" | "both";
}) => {
const { ref, width, height } = useAutoResize({
property,
});
const transitionProperty = property === "both" ? "height, width" : property;
return (
<div
style={{
height,
width,
overflow: overflow ? "visible" : "hidden",
transitionDuration: `${duration}ms`,
transitionTimingFunction: "cubic-bezier(0.19, 1, 0.22, 1)",
transitionProperty,
}}
className={className}
>
<div ref={ref}>{children}</div>
</div>
);
};
import { ComponentPropsWithoutRef, ElementType } from "react";
export type BoxProps<T extends ElementType> = {
as: T;
flexDirection?: "row" | "column";
alignItems?: "center" | "flex-start" | "flex-end" | "stretch";
justifyContent?:
| "center"
| "flex-start"
| "flex-end"
| "space-between"
| "stretch";
gap?: number;
} & ComponentPropsWithoutRef<T>;
export function Box<T extends ElementType>({
as: Component,
gap = 16,
flexDirection = "row",
style,
alignItems = "center",
justifyContent = "space-between",
...rest
}: BoxProps<T>) {
const props = {
style: {
display: "flex",
flexDirection,
alignItems,
justifyContent,
gap,
...style,
},
...rest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return <Component {...props} />;
}
import styles from "./styles.module.scss";
export const Button = ({
children,
wide,
...props
}: {
children: React.ReactNode;
wide?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button
className={styles.button}
{...props}
style={{ width: wide ? "100%" : "auto", ...props.style }}
>
{children}
</button>
);
};
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
font-family: var(--font-primary);
font-size: 0.875rem;
font-weight: 500;
text-align: center;
color: var(--color);
border-radius: 0.75rem;
corner-shape: superellipse(1.25);
background: var(--body-light);
transition: 100ms ease;
transition-property: background, transform;
will-change: transform;
svg {
display: block;
transform: translateX(-0.375rem);
}
&:active {
transform: scale(0.96);
}
&:hover {
background: var(--body-light);
}
&[disabled] {
background: var(--body-light);
pointer-events: none;
}
}
import { useState } from "react";
import styles from "./styles.module.scss";
import { AutoResize } from "../auto-resizer";
import { TextMorph } from "torph/react";
import { useHaptics } from "../../hooks/useHaptics";
export const CodeBlock = ({
code,
children,
}: {
code: string;
children?: React.ReactNode;
}) => {
const { trigger } = useHaptics();
const [isCopied, setIsCopied] = useState(false);
return (
<div className={styles.container}>
<button
className={styles.copy}
onClick={() => {
if (code) {
setIsCopied(true);
navigator.clipboard.writeText(code.toString());
trigger();
setTimeout(() => {
setIsCopied(false);
}, 2000);
}
}}
>
<TextMorph>{isCopied ? "Copied" : "Copy"}</TextMorph>
</button>
<pre>
<AutoResize>{children ?? code}</AutoResize>
</pre>
</div>
);
};
.container {
position: relative;
width: 100%;
border-radius: 0.75rem;
corner-shape: superellipse(1.25);
overflow: hidden;
box-shadow: 0 0 0 1px var(--border);
background-color: var(--body);
.copy {
z-index: 99;
position: absolute;
right: 0;
top: 0;
padding: 1.1rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: -0.00563rem;
transition: color 200ms ease;
color: var(--color-muted);
background-color: var(--body);
mask-image: linear-gradient(to right, transparent, black 1.25rem);
&:hover {
color: var(--color);
}
}
pre {
display: block;
position: relative;
padding: 1rem 1.25rem;
color: var(--color);
overflow: hidden;
font-size: 0.8125rem;
line-height: 1.4;
max-height: 11.75rem;
overflow-y: scroll;
}
}
import * as React from "react";
import styles from "./styles.module.scss";
import { AnimatePresence, motion } from "motion/react";
import { useClickOutside } from "../../hooks/useClickOutside";
export const Dropdown = ({
children,
options,
}: {
children?: React.ReactNode;
options: {
label: string;
icon?: React.ReactNode;
disabled?: boolean;
onClick: () => void;
}[];
}) => {
const [open, setOpen] = React.useState(false);
const { ref } = useClickOutside<HTMLDivElement>(() => setOpen(false));
return (
<div ref={ref} className={styles.container}>
<button onClick={() => setOpen(!open)} className={styles.trigger}>
{children}
<motion.svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
animate={{
rotate: open ? -180 : 0,
}}
transition={{ duration: 0.2, ease: [0.19, 1, 0.22, 1] }}
>
<path
d="M4 6L8 10L12 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
</button>
<AnimatePresence mode="popLayout" initial={false}>
{open && (
<motion.div
className={styles.dropdown}
initial={{ opacity: 0, scale: 0.95, originY: 0, originX: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.19, 1, 0.22, 1] }}
>
<div className={styles.content}>
{options.map((option, index) => (
<button
key={index}
onClick={() => {
option.onClick();
setOpen(false);
}}
disabled={option.disabled}
>
{option.icon && (
<span className={styles.icon}>{option.icon}</span>
)}
<span>{option.label}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
.container {
position: relative;
}
.trigger {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-muted);
padding: 0.5rem;
border-radius: 0.5rem;
transition: all 100ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
.dropdown {
z-index: 2147483647;
position: absolute;
top: 100%;
left: 0;
.content {
display: flex;
flex-direction: column;
padding: 0.25rem;
border-radius: 0.75rem;
background: #101010;
box-shadow: 0 0.125rem 1rem 0.25rem rgba(0, 0, 0, 0.6);
transform: translateY(0.5rem) translateX(-0.65rem);
button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
padding-right: 2rem;
color: #ffffff;
font-size: 0.9375rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 100ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&:disabled {
pointer-events: none;
opacity: 0.2;
}
}
}
}
.item {
cursor: default;
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
padding-right: 1.5rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.00563rem;
&[data-disabled] {
opacity: 0.4;
pointer-events: none;
}
&:hover,
&[data-state="checked"],
&[data-highlighted] {
cursor: pointer;
background-color: var(--bg-3);
transition-duration: 0ms;
&[data-tone="danger"] {
background: #fee2e2;
color: #ff6969;
}
}
&:active {
transform: scale(0.99);
}
&[data-tone="danger"] {
color: #ff5858;
svg {
color: #ff6969;
}
&:hover {
background: #fee2e2;
color: #ff6969;
}
}
}
.label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
svg {
color: var(--fg-3);
}
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
border-radius: 2rem;
color: var(--fg-max);
svg {
display: block;
}
}
.subLabel {
padding-left: 1rem;
margin-left: auto;
color: var(--fg-3);
font-weight: 400;
letter-spacing: -0.24px;
}
.modal {
display: flex;
flex-direction: column;
.item {
width: 100%;
padding: 0.75rem 0;
.label {
color: var(--fg-max);
font-size: 1rem;
font-weight: 500;
line-height: 1.125rem; /* 112.5% */
letter-spacing: -0.01125rem;
gap: 0.75rem;
}
}
}
import styles from "./styles.module.scss";
import { version } from "web-haptics";
export const Footer = () => {
return (
<footer className={styles.footer}>
<div className={styles.credits}>
Crafted for the web by
<a
href="https://twitter.com/lochieaxon"
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://lochie.me/avatar.jpg"
alt="Avatar of Lochie"
width={20}
height={20}
/>
Lochie
</a>
</div>
<div className={styles.details}>
<span>v{version}</span> •
<a
href="https://github.com/lochie/web-haptics"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
</div>
</footer>
);
};
.footer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 4rem 0 0;
letter-spacing: -0.00563rem;
font-size: 0.875rem;
font-style: normal;
font-weight: 400;
line-height: 1.25rem;
color: var(--color-muted);
.credits {
display: flex;
align-items: center;
gap: 0.25rem;
a {
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
padding: 0.375rem;
padding-right: 0.675rem;
border-radius: 3rem;
background-color: var(--body);
transition: background-color 200ms ease;
img {
display: block;
position: relative;
z-index: 1;
overflow: hidden;
border-radius: 50%;
}
&:hover {
background-color: var(--body-light);
}
}
}
.details {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-light);
a,
span {
color: var(--color-muted);
}
a {
transition: color 100ms ease;
&:hover {
color: var(--color);
}
}
}
}
import { useEffect, useRef, forwardRef } from "react";
import styles from "./styles.module.scss";
type HorizontalScrollProps = {
children: React.ReactNode;
style?: React.CSSProperties;
onHover?: (e: Event) => void;
};
export const HorizontalScroll = forwardRef<
HTMLDivElement,
HorizontalScrollProps
>(({ children, style, onHover: onHoverCallback }, ref) => {
const fallbackRef = useRef<HTMLDivElement>(null);
// Actual ref instance — parent ref or fallback
const elementRef = (ref as React.RefObject<HTMLDivElement>) ?? fallbackRef;
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onHover = (e: Event) => {
onHoverCallback?.(e);
};
const onScrollWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onScrollWheel, { passive: false });
el.addEventListener("mouseenter", onHover);
return () => {
el.removeEventListener("wheel", onScrollWheel);
el.removeEventListener("mouseenter", onHover);
};
}, [elementRef, onHoverCallback]);
return (
<div className={styles.container} style={style}>
<div className={styles.scrollarea} ref={elementRef}>
{children}
</div>
</div>
);
});
HorizontalScroll.displayName = "HorizontalScroll";
.container {
position: relative;
overflow: hidden;
min-width: 0;
&::before,
&::after {
content: "";
position: absolute;
top: 0;
bottom: 4px;
z-index: 10;
pointer-events: none;
}
--bg-max: var(--body);
--left-mask-width: 1rem;
--right-mask-width: 1rem;
margin: 0 -0.5rem;
&::before {
left: 0;
width: var(--left-mask-width, 0);
background: linear-gradient(
to right,
var(--bg-max) 0%,
color-mix(in srgb, var(--bg-max) 98%, transparent) 8%,
color-mix(in srgb, var(--bg-max) 94%, transparent) 16%,
color-mix(in srgb, var(--bg-max) 85%, transparent) 26%,
color-mix(in srgb, var(--bg-max) 70%, transparent) 38%,
color-mix(in srgb, var(--bg-max) 50%, transparent) 50%,
color-mix(in srgb, var(--bg-max) 30%, transparent) 62%,
color-mix(in srgb, var(--bg-max) 15%, transparent) 74%,
color-mix(in srgb, var(--bg-max) 6%, transparent) 84%,
color-mix(in srgb, var(--bg-max) 2%, transparent) 92%,
transparent 100%
);
}
&::after {
right: 0;
width: var(--right-mask-width, 0);
background: linear-gradient(
to left,
var(--bg-max) 0%,
color-mix(in srgb, var(--bg-max) 98%, transparent) 8%,
color-mix(in srgb, var(--bg-max) 94%, transparent) 16%,
color-mix(in srgb, var(--bg-max) 85%, transparent) 26%,
color-mix(in srgb, var(--bg-max) 70%, transparent) 38%,
color-mix(in srgb, var(--bg-max) 50%, transparent) 50%,
color-mix(in srgb, var(--bg-max) 30%, transparent) 62%,
color-mix(in srgb, var(--bg-max) 15%, transparent) 74%,
color-mix(in srgb, var(--bg-max) 6%, transparent) 84%,
color-mix(in srgb, var(--bg-max) 2%, transparent) 92%,
transparent 100%
);
}
}
.scrollarea {
display: flex;
overflow-x: auto;
overflow-y: hidden;
padding: 0 0.5rem;
&:after {
content: "";
width: 1rem;
}
@media (hover: none) {
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
}
--size: 0px;
--thumb: rgb(224, 224, 224);
--track: whiteSmoke;
&::-webkit-scrollbar {
width: var(--size);
height: var(--size);
}
&::-webkit-scrollbar-track {
background: transparent;
background: var(--track);
}
&::-webkit-scrollbar-thumb {
border-radius: 100px;
background: var(--thumb);
}
&:hover::-webkit-scrollbar-thumb {
background: var(--thumb);
}
&::-webkit-scrollbar-thumb:hover {
}
}
import styles from "./styles.module.scss";
import React, {
createContext,
useContext,
useRef,
useCallback,
useEffect,
} from "react";
export type EmojiOption = { emoji: string; canFlip: boolean };
type ParticlesContextValue = {
create: (
x: number,
y: number,
emojis: EmojiOption[],
duration?: number,
gx?: number,
gy?: number,
) => void;
};
interface Particle {
x: number;
y: number;
xv: number;
yv: number;
a: number;
s: number;
opacity: number;
life: number;
maxLife: number;
emoji: string;
flipH: boolean;
fontSize: number;
radius: number;
gx: number;
gy: number;
}
const ParticlesContext = createContext<ParticlesContextValue | null>(null);
export const useParticles = () => {
const ctx = useContext(ParticlesContext);
if (!ctx) {
throw new Error("useParticles must be used within a ParticlesProvider");
}
return ctx;
};
const MAX_ACTIVE = 500;
const ANIM_FRAMES = 120;
const MAX_DPR = 2;
// --- Emoji cache ---
const emojiCache = new Map<string, HTMLCanvasElement>();
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
const existing = emojiCache.get(emoji);
if (existing) return existing;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const CANONICAL_PX = 64;
const fontSize = Math.ceil(CANONICAL_PX * dpr);
// Pad canvas so glyphs that overflow the em-square aren't clipped
const size = Math.ceil(fontSize * 1.5);
const offscreen = document.createElement("canvas");
offscreen.width = size;
offscreen.height = size;
const ctx = offscreen.getContext("2d")!;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${fontSize}px serif`;
ctx.fillText(emoji, size / 2, size / 2);
emojiCache.set(emoji, offscreen);
return offscreen;
}
// --- Physics ---
function updateParticle(p: Particle): boolean {
p.a += p.xv * 0.5;
p.yv *= 0.9;
p.y += p.yv;
p.xv *= 0.98;
p.x += p.xv;
p.s += (1 - p.s) * 0.3;
p.xv += p.gx * 0.1;
p.yv += (p.gy + p.yv) * 0.1;
p.radius = p.fontSize * p.s * 0.5;
p.life--;
const lifeRatio = p.life / p.maxLife;
if (lifeRatio < 0.25) {
p.opacity = lifeRatio / 0.25;
}
return p.life > 0 && p.opacity > 0.01;
}
function resolveCollisions(particles: Particle[]) {
const n = particles.length;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = particles[i];
const b = particles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const distSq = dx * dx + dy * dy;
const minDist = a.radius + b.radius;
if (distSq < minDist * minDist && distSq > 0.0001) {
const dist = Math.sqrt(distSq);
const nx = dx / dist;
const ny = dy / dist;
// Push apart
const overlap = minDist - dist;
const sep = overlap * 0.5;
a.x -= nx * sep;
a.y -= ny * sep;
b.x += nx * sep;
b.y += ny * sep;
// Elastic velocity exchange along normal
const dvx = a.xv - b.xv;
const dvy = a.yv - b.yv;
const dvDotN = dvx * nx + dvy * ny;
if (dvDotN > 0) {
const restitution = 0.5;
const impulse = dvDotN * restitution;
a.xv -= impulse * nx;
a.yv -= impulse * ny;
b.xv += impulse * nx;
b.yv += impulse * ny;
}
}
}
}
}
// --- Canvas sizing ---
function resizeCanvas(canvas: HTMLCanvasElement) {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const width = window.innerWidth;
const height = window.innerHeight;
const targetW = Math.round(width * dpr);
const targetH = Math.round(height * dpr);
if (canvas.width !== targetW || canvas.height !== targetH) {
canvas.width = targetW;
canvas.height = targetH;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
}
// --- Spawning ---
function spawnBurst(
particles: Particle[],
x: number,
y: number,
emojis: EmojiOption[],
gx: number,
gy: number,
) {
const amount = 4;
if (particles.length + amount > MAX_ACTIVE) return;
for (let i = 0; i < amount; i++) {
const xv = Math.random() * 16 - 8;
const yv =
(i === 0 ? 4 : i === 1 ? 8 : i === 2 ? 8 : 0) *
(0.25 + Math.random() * 0.25);
const pick = emojis[Math.floor(Math.random() * emojis.length)];
particles.push({
x,
y,
xv,
yv,
a: 0,
s: 0.2,
opacity: 1,
life: ANIM_FRAMES,
maxLife: ANIM_FRAMES,
emoji: pick?.emoji || "\u2728",
flipH: pick?.canFlip ? Math.random() < 0.5 : false,
fontSize: 20 + Math.ceil(Math.random() * 40),
radius: 0,
gx,
gy,
});
}
}
// --- Component ---
export const ParticlesProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafIdRef = useRef<number | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const startLoop = useCallback(() => {
if (rafIdRef.current !== null) return; // already running
const canvas = canvasRef.current;
if (!canvas || !ctxRef.current) return;
const ctx = ctxRef.current;
function frame() {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const particles = particlesRef.current;
// Update physics, cull dead particles (swap-and-pop)
for (let i = particles.length - 1; i >= 0; i--) {
if (!updateParticle(particles[i])) {
particles[i] = particles[particles.length - 1];
particles.pop();
}
}
// Stop loop when idle
if (particles.length === 0) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas!.width, canvas!.height);
rafIdRef.current = null;
return;
}
resolveCollisions(particles);
// Clear
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas!.width, canvas!.height);
// Draw particles batched by opacity: full-opacity first, then fading
ctx.globalAlpha = 1;
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const isFading = p.opacity < 1;
if (pass === 0 && isFading) continue;
if (pass === 1 && !isFading) continue;
if (pass === 1) {
ctx.globalAlpha = p.opacity;
}
const emojiImg = getEmojiCanvas(p.emoji);
const drawSize = p.fontSize * p.s * 1.5;
const halfSize = drawSize / 2;
// Compute transform matrix directly: translate(x,y) * rotate(a) * scale(dpr)
// Avoids save()/restore() which is slow on iOS Safari
const rad = (p.a * Math.PI) / 180;
const cos = Math.cos(rad) * dpr;
const sin = Math.sin(rad) * dpr;
const fx = p.flipH ? -1 : 1;
ctx.setTransform(cos * fx, sin * fx, -sin, cos, p.x * dpr, p.y * dpr);
ctx.drawImage(emojiImg, -halfSize, -halfSize, drawSize, drawSize);
}
}
rafIdRef.current = requestAnimationFrame(frame);
}
rafIdRef.current = requestAnimationFrame(frame);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
ctxRef.current = canvas.getContext("2d") as CanvasRenderingContext2D;
resizeCanvas(canvas);
const onResize = () => resizeCanvas(canvas);
window.addEventListener("resize", onResize);
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
window.removeEventListener("resize", onResize);
};
}, []);
const create = useCallback(
(
x: number,
y: number,
emojis: EmojiOption[] = [{ emoji: "\u2728", canFlip: false }, { emoji: "\uD83D\uDD25", canFlip: false }],
duration?: number,
gx: number = 0,
gy: number = -1.5,
) => {
const particles = particlesRef.current;
spawnBurst(particles, x, y, emojis, gx, gy);
startLoop();
if (duration && duration > 0) {
const interval = 150;
const count = Math.floor(duration / interval);
for (let i = 1; i <= count; i++) {
setTimeout(() => {
spawnBurst(particles, x, y, emojis, gx, gy);
startLoop();
}, i * interval);
}
}
},
[startLoop],
);
return (
<ParticlesContext.Provider value={{ create }}>
{children}
<canvas ref={canvasRef} className={styles.particles} />
</ParticlesContext.Provider>
);
};
.particles {
z-index: 214748365;
position: fixed;
overflow: hidden;
inset: 0;
pointer-events: none;
user-select: none;
contain: strict;
}
import { useId } from "react";
import styles from "./styles.module.scss";
import { LayoutGroup, motion } from "motion/react";
import { useHaptics } from "../../hooks/useHaptics";
export const ToggleGroup = ({
children,
disabled,
}: {
children: React.ReactNode;
disabled?: boolean;
}) => {
const id = useId();
return (
<LayoutGroup id={id}>
<div
className={styles.group}
style={{
pointerEvents: disabled ? "none" : "auto",
}}
>
{children}
</div>
</LayoutGroup>
);
};
export const Toggle = ({
children,
active,
...props
}: {
children: React.ReactNode;
active?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { trigger } = useHaptics();
return (
<button
className={styles.toggle}
{...props}
data-active={active}
onClick={(e) => {
if (active) {
e.preventDefault();
return;
}
trigger();
props.onClick?.(e);
}}
>
{active && (
<motion.span
layoutId="toggle-indicator"
className={styles.indicator}
transition={{
duration: 0.1,
}}
/>
)}
<span className={styles.content}>{children}</span>
</button>
);
};
.group {
display: flex;
align-items: center;
justify-content: stretch;
padding: 2px;
border-radius: 2rem;
background: #f6f7f9;
}
.toggle {
position: relative;
flex: 1;
padding: 0.75rem 1.25rem;
border-radius: 10rem;
color: var(--color-muted);
transition: 100ms ease;
transition-property: background, transform;
will-change: transform;
.indicator {
z-index: -1;
position: absolute;
inset: 0;
background: #ffffff;
border-radius: inherit;
}
.content {
z-index: 2;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
font-family: var(--font-primary);
font-size: 1.0625rem;
font-weight: 600;
text-align: center;
}
svg {
display: block;
transform: translateX(-0.375rem);
}
&[data-active="true"] {
cursor: default;
color: var(--color);
transform: none !important;
}
&:active {
transform: scale(0.96);
}
&[disabled] {
pointer-events: none;
}
}
import { createContext, useContext, useState } from "react";
type AppContextValue = {
debug: boolean;
setDebug: (debug: boolean) => void;
};
const AppContext = createContext<AppContextValue | null>(null);
export const useApp = () => {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error("useApp must be used within an AppProvider");
}
return ctx;
};
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [debug, setDebug] = useState(() => !("ontouchstart" in window));
return (
<AppContext.Provider value={{ debug, setDebug }}>
{children}
</AppContext.Provider>
);
};
import { useEffect, useRef, useState } from "react";
export const useAutoResize = ({
property = "height",
}: {
property?: "height" | "width" | "both";
}) => {
// ref can be attached to any HTML element
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | "auto">("auto");
const [width, setWidth] = useState<number | "auto">("auto");
useEffect(() => {
if (ref.current) {
const resizeObserver = new ResizeObserver((entries) => {
const observedHeight = entries[0].contentRect.height;
const observedWidth = entries[0].contentRect.width;
setHeight(observedHeight);
setWidth(observedWidth);
});
resizeObserver.observe(ref.current);
return () => {
resizeObserver.disconnect();
};
}
}, []);
const heightValue = property === "height" ? height : "auto";
const widthValue = property === "width" ? width : "auto";
return {
ref,
height: heightValue,
width: widthValue,
};
};
import React from "react";
export const useClickOutside = <T extends HTMLElement = HTMLElement>(
handler: () => void,
) => {
const ref = React.useRef<T>(null);
React.useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler();
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [handler]);
return { ref };
};
import { useCallback } from "react";
import { useWebHaptics } from "web-haptics/react";
import type { HapticInput, TriggerOptions } from "web-haptics";
import { useApp } from "../context/app";
import { shakeFavicon } from "../utils/faviconShake";
// abstraction so we can do some extra stuff
export const useHaptics = () => {
const { debug } = useApp();
const { trigger } = useWebHaptics({ debug });
const triggerWithShake = useCallback(
(input?: HapticInput, options?: TriggerOptions) => {
shakeFavicon();
return trigger(input, options);
},
[trigger],
);
return {
trigger: triggerWithShake,
};
};
code,
pre {
font-family: var(--font-code);
}
code {
position: relative;
display: inline-block;
font-size: 1rem;
line-height: 1;
padding: 1rem;
user-select: all;
&:before {
pointer-events: none;
content: "";
position: absolute;
inset: 0;
border-radius: 0.75rem;
background: rgba(17, 17, 17, 0.06);
}
}
Related skills
FAQ
Does web-haptics work on desktop browsers?
The web-haptics library silently no-ops on unsupported platforms such as desktop browsers. The skill does not require explicit feature detection or error handling for missing vibration support.
How do you install web-haptics in a project?
The web-haptics skill installs the package with npm i web-haptics, then wires trigger() calls on buttons, toggles, forms, and pickers. The library is MIT-licensed with zero dependencies at version 1.0.
Is Web Haptics safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.