@mxerf/tappt
| Install | |
|---|---|
npm install @mxerf/tappt |
|
| Latest Version: | 0.2.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Sep 25, 2026 |
| Links: | GitHub · npm |
tappt
Маленькая SSR-безопасная библиотека тактильной отдачи для веба. Работает в Telegram Mini Apps, iOS Safari 17.4+ (через Taptic Engine и «switch»-хак, на iOS 26.5+ — через attach()) и везде, где доступен Vibration API. На неподдерживаемых платформах молча ничего не делает.
- ~2.4 КБ min+gzip в core, нулевые runtime-зависимости
- Ленивая feature-детекция, без user-agent sniffing
- Встроенные адаптеры для React и Vue 3
- TypeScript-first, поставляется в ESM + CJS +
.d.ts - SSR-безопасно: импорт на сервере не трогает
document
Зачем
Возможностей для тактильной отдачи в вебе немного, и они разрозненны:
- Telegram Mini Apps предоставляют
window.Telegram.WebApp.HapticFeedbackс полноценным APIimpact / notification / selection. - iOS Safari 17.4+ получил элемент
<input type="checkbox" switch>: при переключении он вызывает настоящий импульс Taptic Engine. Это единственный способ запустить нативную вибрацию в iOS-PWA — публичного API для этого нет, аnavigator.vibrateна iOS не реализован. С iOS 26.5 WebKit даёт этот импульс только на настоящий тап по свитчу или его label (баг 309082) — для этого и нуженattach(). - Android и остальные браузеры предоставляют
navigator.vibrate(pattern).
При первом вызове tappt выбирает лучший доступный бэкенд и даёт единый стабильный API.
Установка
bun add @mxerf/tappt
# или
npm i @mxerf/tappt
# или
pnpm add @mxerf/tappt
React и Vue объявлены как опциональные peer-зависимости — они нужны только если ты импортируешь @mxerf/tappt/react или @mxerf/tappt/vue.
Использование
Vanilla
import { haptic } from "@mxerf/tappt";
button.addEventListener("click", () => haptic.impact("medium"));
form.addEventListener("submit", () => haptic.notify("success"));
tabs.addEventListener("change", () => haptic.selection());
Либо именованные функции напрямую:
import { impact, notify, selection } from "@mxerf/tappt";
impact("light");
notify("error");
selection();
iOS 26.5+: attach()
Начиная с iOS 26.5, Safari включает Taptic Engine, только когда палец попадает в настоящий свитч. Хаптик из обработчика клика там молчит, если элемент не подключён через attach():
import { haptic } from "@mxerf/tappt";
haptic.attach(button);
button.addEventListener("click", () => haptic.impact("medium"));
attach() кладёт поверх элемента прозрачный <label>, связанный со скрытым свитчем. Палец попадает в label, элемент получает копию клика, а первый хаптик, запрошенный внутри этого клика, срабатывает от самого тапа. Если обработчик хаптик не запросил, свитч не переключается, поэтому хаптики по условию продолжают работать:
haptic.attach(save);
save.addEventListener("click", () => {
if (form.checkValidity()) haptic.notify("success");
});
Чтобы хаптик срабатывал на каждый тап без обработчика, передай событие. attach() возвращает функцию, которая снимает оверлей:
const detach = haptic.attach(tab, { kind: "selection" });
detach();
На остальных бэкендах attach() без события ничего не делает, а с событием просто вешает обработчик клика, так что вызывать его можно везде. Хаптики вне подключённых элементов работают как раньше: в Telegram, на Android и на iOS 17.4–26.4.
Discriminated-событие
Удобно, когда нужно пробросить тип haptic-отклика через пропсы компонента:
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" });
Scoped-инстансы
Для тестов, локальных opt-out или принудительного выбора бэкенда:
import { createHaptic } from "@mxerf/tappt";
const haptic = createHaptic({
backend: "vibration", // форсировать бэкенд
disabled: false, // true → все вызовы становятся no-op
});
haptic.impact();
haptic.destroy(); // освобождает iOS-rig и внутреннее состояние
React
import { useHaptic, useHapticRef, TapptProvider } from "@mxerf/tappt/react";
function LikeButton() {
const haptic = useHaptic();
const ref = useHapticRef<HTMLButtonElement>(); // нужен на iOS 26.5+
return <button ref={ref} onClick={() => haptic.impact("medium")}>Лайк</button>;
}
// Или декларативно, без обработчика
function NextButton() {
return <button ref={useHapticRef({ kind: "selection" })}>Дальше</button>;
}
// По желанию: отдельный инстанс для поддерева
export function App() {
return (
<TapptProvider options={{ disabled: userPrefersNoHaptics }}>
<LikeButton />
</TapptProvider>
);
}
Без TapptProvider хук useHaptic() возвращает общий синглтон на уровне модуля — никакой настройки не требуется.
Vue 3
<script setup lang="ts">
import { useHaptic, vHaptic } from "@mxerf/tappt/vue";
const haptic = useHaptic();
</script>
<template>
<!-- v-haptic нужен на iOS 26.5+ -->
<button v-haptic @click="haptic.impact('medium')">Лайк</button>
<!-- Или декларативно, без обработчика -->
<button v-haptic="{ kind: 'selection' }">Дальше</button>
</template>
Подключить плагином, чтобы зарегистрировать общий экземпляр на всё приложение. Плагин заодно глобально регистрирует v-haptic, привязанный к этому экземпляру, — импортировать vHaptic тогда не нужно:
import { createApp } from "vue";
import { tapptPlugin } from "@mxerf/tappt/vue";
createApp(App).use(tapptPlugin({ disabled: false })).mount("#app");
Или создать инстанс, привязанный к компоненту — он автоматически уничтожится на unmount:
const haptic = useHaptic({ scoped: true, options: { backend: "vibration" } });
API
Типы
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" };
Методы
| Метод | Описание |
|---|---|
impact(style?) |
Короткий тап — кнопки, тумблеры, конец drag-жеста. |
notify(type?) |
Отклик на событие — успех, предупреждение, ошибка в формах. |
selection() |
Очень лёгкий тап — переключение вкладок, шаги пикера. |
trigger(event) |
Отправить discriminated-событие HapticEvent. |
attach(element, event?) |
Чтобы тапы по элементу давали хаптик на iOS 26.5+. Возвращает функцию отключения. |
getBackend() |
Какой бэкенд сейчас активен (noop, если ничего не подошло). |
isSupported() |
true, если есть любой работающий бэкенд. |
destroy() |
Освободить DOM-элементы, снять attach() со всех элементов и сбросить внутреннее состояние. Последующие вызовы станут no-op. |
Опции
createHaptic({
backend: "telegram" | "ios-switch" | "vibration" | "noop",
disabled: boolean,
});
Приоритет бэкендов
При первом вызове tappt берёт первый доступный из списка:
telegram—window.Telegram.WebApp.HapticFeedback. Лучшее качество внутри Telegram-клиентов.ios-switch— скрытый<input type="checkbox" switch>. Только iOS Safari 17.4+.vibration—navigator.vibrate(pattern). Android и большинство десктопных браузеров.noop— тихий fallback (старые iOS, браузеры с ограничениями, SSR).
Передай backend: "..." в createHaptic(), чтобы выбрать конкретный бэкенд принудительно — например, пропустить Telegram даже внутри Mini App.
SSR
Все API безопасно импортировать на сервере. Определение бэкенда откладывается до первого реального вызова impact() / notify() / selection() / trigger() — можно спокойно создавать const haptic = createHaptic() на уровне модуля и переиспользовать один и тот же экземпляр на клиенте и на сервере, без дополнительных проверок. attach() принимает DOM-элемент, поэтому выполняется только на клиенте — useHapticRef и v-haptic об этом уже заботятся.
Матрица возможностей
Не каждый бэкенд умеет всё. tappt всегда что-то вызывает, но что именно почувствует пользователь — зависит от платформы:
| Метод | Telegram Mini App | iOS Safari 17.4–26.4 (ios-switch) |
iOS Safari 26.5+ (элемент с attach()) |
Android / Vibration API | Noop |
|---|---|---|---|---|---|
impact("light") |
Различимо | Один пульс (стиль игнорируется) | Один пульс (стиль игнорируется) | 8 мс вибрации | — |
impact("medium") |
Различимо | Один пульс (стиль игнорируется) | Один пульс (стиль игнорируется) | 15 мс вибрации | — |
impact("heavy") |
Различимо | Один пульс (стиль игнорируется) | Один пульс (стиль игнорируется) | 25 мс вибрации | — |
impact("rigid") |
Различимо | Один пульс (стиль игнорируется) | Один пульс (стиль игнорируется) | 25 мс вибрации | — |
impact("soft") |
Различимо | Один пульс (стиль игнорируется) | Один пульс (стиль игнорируется) | 8 мс вибрации | — |
notify("success") |
Различимо | 2 пульса | 1 пульс | паттерн [12, 40, 12] |
— |
notify("warning") |
Различимо | 2 пульса | 1 пульс | паттерн [10, 40, 10] |
— |
notify("error") |
Различимо | 3 пульса | 1 пульс | [10, 60, 10, 60, 10] |
— |
selection() |
Различимо | Один пульс | Один пульс | 5 мс вибрации | — |
На iOS 26.5+ хаптик от элемента без attach() не ощущается вовсе.
Известные ограничения
- iOS Safari не различает стили
impact. Элемент<input type="checkbox" switch>даёт ровно один тип Taptic-импульса — публичного iOS Web API, который давал бы полный доступ кUIImpactFeedbackGenerator, не существует.impact("light")иimpact("heavy")в Safari ощущаются одинаково. В Telegram Mini App на iOS они различаются: там клиент передаёт вызов в нативный слой. - Поведение Vibration API на Android сильно зависит от устройства. Некоторые телефоны обрезают короткую вибрацию (меньше 10 мс), другие игнорируют паттерны в режиме энергосбережения. Не закладывай смысл в тонкие различия длительности — UX должен работать, даже если любой отклик означает просто «что-то произошло».
- На iOS 26.5+ ощущается только первый хаптик внутри тапа по элементу с
attach(). Хаптики послеawait, из таймеров и от элементов безattach()молчат, аnotify()сжимается до одного пульса. - Обработчики клика у подключённого элемента получают копию клика. У неё
isTrustedравенfalse, аtarget— сам элемент. Действие по умолчанию — отправка формы, переход по ссылке — по-прежнему выполняется один раз. - Оверлей закрывает дочерние элементы. Подключай кнопки и другие конечные контролы, а не контейнеры с интерактивным содержимым внутри. Элемент со статическим позиционированием получает
position: relative, а дочерние элементы, поднятые над оверлеем черезz-index, принимают тапы в обход него. notify()на iOS Safari 17.4–26.4 эмулируется серией пульсов с паузой 55 мс. Вызовы, идущие подряд, ставятся в очередь и не накладываются друг на друга.navigator.vibrateсрабатывает только в ответ на действие пользователя в большинстве браузеров.tapptне обходит это ограничение — вызывай haptic-методы из обработчиков click/touch.
Поддержка браузеров
- Telegram Mini Apps (iOS + Android)
- iOS Safari 17.4+ (PWA или в браузере; на 26.5+ нужен
attach()) - Chrome, Edge, Firefox, Samsung Internet (Android) — через Vibration API
- Всё остальное — тихий no-op
Лицензия
MIT
Related Packages
Haptic feedback for Vue 3 that also works on iOS Safari, where the Vibration API...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.0 | >=3.3 | MIT | |
| 0.1.4 | >=3.3 | MIT | |
| 0.1.3 | >=3.3 | MIT | |
| 0.1.2 | >=3.3 | MIT | |
| 0.1.1 | >=3.3 | MIT | |
| 0.1.0 | >=3.3 | MIT |