Tiny, SSR-safe haptic feedback for the web. Works in Telegram Mini Apps, iOS Safari 17.4+ (via the Taptic Engine "switch" hack), and anywhere the Vibration API is available. Silent no-op on unsupported platforms.
- ~1.4 KB min+gzip core, zero runtime dependencies
- Lazy feature detection, no user-agent sniffing
- Built-in adapters for React and Vue 3
- TypeScript-first, ships ESM + CJS +
.d.ts - SSR-safe: importing on the server never touches
document
Browsers give you fragmented options for haptic feedback:
- Telegram Mini Apps expose
window.Telegram.WebApp.HapticFeedbackwith a fullimpact / notification / selectionAPI. - iOS Safari 17.4+ gained the
<input type="checkbox" switch>element, which produces a real Taptic Engine pulse when toggled — the only way to trigger native haptics on iOS PWAs. There is no public API for this;navigator.vibrateis not implemented on iOS. - Android and other browsers expose
navigator.vibrate(pattern).
tappt picks the best available backend at runtime and gives you a single, stable API.
bun add @mxerf/tappt
# or
npm i @mxerf/tappt
# or
pnpm add @mxerf/tapptReact and Vue are declared as optional peer dependencies — you only need them installed if you import tappt/react or tappt/vue.
import { haptic } from "@mxerf/tappt";
button.addEventListener("click", () => haptic.impact("medium"));
form.addEventListener("submit", () => haptic.notify("success"));
tabs.addEventListener("change", () => haptic.selection());Or use the named functions directly:
import { impact, notify, selection } from "@mxerf/tappt";
impact("light");
notify("error");
selection();Useful when you want to pass the haptic intent through component props:
import { trigger, type HapticEvent } from "@mxerf/tappt";
function handleAction(event: HapticEvent) {
trigger(event);
}
handleAction({ kind: "impact", style: "heavy" });
handleAction({ kind: "notification", type: "warning" });
handleAction({ kind: "selection" });For tests, per-feature opt-outs, or forced backends:
import { createHaptic } from "@mxerf/tappt";
const haptic = createHaptic({
backend: "vibration", // force a specific backend
disabled: false, // set true to make every call a no-op
});
haptic.impact();
haptic.destroy(); // releases the iOS rig and internal stateimport { useHaptic, TapptProvider } from "@mxerf/tappt/react";
function LikeButton() {
const haptic = useHaptic();
return <button onClick={() => haptic.impact("medium")}>Like</button>;
}
// Optional: scope a haptic instance to a subtree
export function App() {
return (
<TapptProvider options={{ disabled: userPrefersNoHaptics }}>
<LikeButton />
</TapptProvider>
);
}Without a provider, useHaptic() returns a shared module-level singleton — zero setup required.
<script setup lang="ts">
import { useHaptic } from "@mxerf/tappt/vue";
const haptic = useHaptic();
</script>
<template>
<button @click="haptic.impact('medium')">Like</button>
</template>Install as a plugin to inject an app-wide instance:
import { createApp } from "vue";
import { tapptPlugin } from "@mxerf/tappt/vue";
createApp(App).use(tapptPlugin({ disabled: false })).mount("#app");Or create a component-scoped instance that auto-destroys on unmount:
const haptic = useHaptic({ scoped: true, options: { backend: "vibration" } });type ImpactStyle = "light" | "medium" | "heavy" | "rigid" | "soft";
type NotificationType = "success" | "warning" | "error";
type BackendName = "telegram" | "ios-switch" | "vibration" | "noop";
type HapticEvent =
| { kind: "impact"; style?: ImpactStyle }
| { kind: "notification"; type?: NotificationType }
| { kind: "selection" };| Method | Description |
|---|---|
impact(style?) |
Short tap — buttons, toggles, drag endpoints. |
notify(type?) |
Event feedback — form success, errors, warnings. |
selection() |
Very light tap — tab switches, picker steps. |
trigger(event) |
Dispatch a discriminated HapticEvent. |
getBackend() |
Returns which backend handled the last call (noop if nothing worked). |
isSupported() |
true if any real backend is available. |
destroy() |
Release the iOS rig and internal state. Subsequent calls are no-ops. |
createHaptic({
backend: "telegram" | "ios-switch" | "vibration" | "noop",
disabled: boolean,
});On first use, tappt picks the first available backend from this list:
telegram—window.Telegram.WebApp.HapticFeedback. Best quality inside Telegram clients.ios-switch— hidden<input type="checkbox" switch>element. iOS 17.4+ Safari only.vibration—navigator.vibrate(pattern). Android and most desktop browsers.noop— silent fallback (old iOS, locked-down browsers, SSR).
Pass backend: "..." to createHaptic() to force a specific one (e.g. skip Telegram even inside a Mini App).
Every API is safe to import on the server. Backend detection is lazy and only runs when you actually call impact() / notify() / selection() / trigger() — so you can share a module-level const haptic = createHaptic() between client and server without guarding it.
Not every backend supports every intensity. tappt always calls something, but what the user feels depends on the platform:
| Method | Telegram Mini App | iOS Safari 17.4+ (ios-switch) |
Android / Vibration API | Noop |
|---|---|---|---|---|
impact("light") |
Distinct | Single pulse (style ignored) | 8 ms vibrate | — |
impact("medium") |
Distinct | Single pulse (style ignored) | 15 ms vibrate | — |
impact("heavy") |
Distinct | Single pulse (style ignored) | 25 ms vibrate | — |
impact("rigid") |
Distinct | Single pulse (style ignored) | 25 ms vibrate | — |
impact("soft") |
Distinct | Single pulse (style ignored) | 8 ms vibrate | — |
notify("success") |
Distinct | 2 pulses | [12, 40, 12] pattern |
— |
notify("warning") |
Distinct | 2 pulses | [10, 40, 10] pattern |
— |
notify("error") |
Distinct | 3 pulses | [10, 60, 10, 60, 10] |
— |
selection() |
Distinct | Single pulse | 5 ms vibrate | — |
- iOS Safari cannot differentiate
impactstyles. The<input type="checkbox" switch>element gives you exactly one kind of Taptic pulse — there is no public iOS web API that exposes the fullUIImpactFeedbackGeneratorsurface.impact("light")andimpact("heavy")feel identical in Safari. Inside a Telegram Mini App on iOS they are distinct, because the TG client forwards the intent natively. - Android Vibration API varies wildly by device. Some phones clip short vibrations below 10 ms, others ignore patterns entirely when battery saver is on. Don't encode meaning into small duration differences — design your UX so that any buzz means "something happened."
notify()on iOS Safari is approximated by repeating pulses. The timing gap is 55 ms. If you callnotifytwice in quick succession, they serialise through an internal queue so pulses don't interleave.navigator.vibraterequires a user gesture in most browsers.tapptdoesn't try to work around this — call haptic methods from real click/touch handlers.
- Telegram Mini Apps (iOS + Android)
- iOS Safari 17.4+ (PWA or in-browser)
- Chrome, Edge, Firefox, Samsung Internet (Android) — via Vibration API
- Everything else — silent no-op
MIT