Designing Haptic.js — A DSL for Tactile Feedback on the Web
How I built a tiny pattern language and a real compiler pipeline so one string can drive vibration on Android, audio fallback on iOS, and dual-motor rumble on a gamepad.
The Web's Haptic Problem
The browser's built-in haptic API is navigator.vibrate(). It takes an array of numbers — alternating on/off durations in milliseconds. That's it. No intensity. No composability. No semantic meaning. No iOS support.
Every web app that wants haptic feedback ends up reinventing the same primitives. I wanted something different — an actual language for haptic patterns, with one API across every haptic-capable surface: mobile vibration, iOS, gamepad rumble, and React Native.
HPL — Haptic Pattern Language
The core idea: patterns are strings. Each character is a token with a defined haptic meaning.
haptic.play('~~..##..@@')
// ~ light pulse
// # medium pulse
// @ heavy pulse
// . pause
// | tap
// - sustain
// [] groups
// xN repeatStrings are easy to share, store in JSON, embed in URLs, and reason about visually. ~~..## reads like sheet music for your fingertips.
Why a Real Compiler?
The naive approach is string-replace: scan the pattern, swap each character for a vibration tuple, ship it. That works until you want repetition (x3), grouping ([~~]x2), nested intensity, or platform-specific optimization. Then you're hand-rolling parser logic inside string-replace and it collapses.
So I built a proper pipeline:
source string
↓ tokenizer
[Token, Token, Token, ...]
↓ parser
AST (Group, Pulse, Repeat, Pause)
↓ optimizer
flattened, deduped, intensity-resolved AST
↓ adapter dispatch
WebVibration | iOSAudio | Gamepad | ReactNativeEach adapter consumes the same AST and renders it for its platform. The compiler doesn't know about platforms; the adapters don't know about syntax. Clean separation, easy to add a fifth backend later.
The Android Bug That Took a Day
My first WebVibration adapter tried to simulate intensity by chunking each pulse into rapid micro-pulses — pulse-width modulation. On paper, a 50ms pulse at 0.5 intensity becomes [5, 5, 5, 5, 5, 5, 5, 5, 5, 5]. Nice trick. Patterns under ~20 effects worked.
Anything longer? Silent. No vibration, no errors, no warnings.
After hours of bisecting pattern lengths, I realized: Android's vibration buffer silently drops patterns above an undocumented length threshold. The PWM trick was burning through the buffer.
The fix was to throw out PWM entirely and pass natural durations directly to navigator.vibrate(pattern). The OS handles intensity via its own haptic profile — I just had to stop being clever. Sometimes the right fix is deleting code.
Cross-Platform Through Adapters
The adapter pattern is what makes the same string work everywhere:
interface HapticAdapter {
isSupported(): boolean
play(ast: PatternAST): Promise<void>
cancel(): void
}
class WebVibrationAdapter implements HapticAdapter { /* navigator.vibrate */ }
class IoSAudioAdapter implements HapticAdapter { /* AudioContext fallback */ }
class GamepadAdapter implements HapticAdapter { /* dual-motor rumble */ }
class ReactNativeAdapter implements HapticAdapter { /* RN Haptics module */ }The runtime picks the first supported adapter. If none match, the noop adapter swallows everything silently — your code never breaks on a desktop browser.
The Ecosystem
The library ships as 9 packages under @hapticjs/* — core, react, vue, svelte, angular, web-components, react-native, gamepad, and a CLI. Plus 63 built-in presets, physics-based pattern generators (spring, bounce, pendulum), a recorder that converts taps to HPL strings, and 383 tests across the monorepo.
A semantic API sits on top for people who don't want to learn HPL:
haptic.tap()
haptic.success()
haptic.error()
haptic.impact('heavy')
// Or compose your own
haptic.play('[~~]x3 . @@')What I Took Away
Designing a tiny language forces you to think about ergonomics in a way an SDK doesn't. Every character is API surface. Every grammar choice is a forever-decision.
Building the compiler myself — instead of pulling in PEG.js or nearley — meant I owned every layer. When the Android bug hit, debugging didn't stop at a black-box parser.
The adapter pattern paid off the first time I added gamepad support — I wrote one file and the whole library suddenly worked on Xbox controllers. That's the moment an abstraction proves itself.
Pattern Recipes
A few HPL strings that show how the language reads. Each one is a real pattern you can play in the playground:
// Heartbeat — two soft thumps with a pause
'@.@...'
// Double-tap confirmation
'||'
// Error shake — three sharp pulses
'[##]x3'
// Button press with release
'@-.|'
// Success chime — light, light, heavy
'~.~.@'
// Long press warning
'~~~~~~~~##'You can read these almost like a waveform. ~ is light, # medium, @ heavy, . a pause, []x3 repeats a group three times. After about ten minutes you stop translating and start feeling them.
Haptics as an Accessibility Layer
The most underrated feature in the library is the accessibility module. Once enabled, it auto-attaches haptic feedback to:
- Focus changes — a soft tap when keyboard navigation moves between elements
- Form errors — a distinct error pattern when validation fails, paired with the visual cue
- Alerts and toasts — a notification pulse synced with the appearance
- State changes — toggle, select, expand all get their own haptic signature
import { enableA11yHaptics } from '@hapticjs/core'
enableA11yHaptics({
profile: 'accessible', // gentler intensity by default
focus: true,
errors: true,
alerts: true,
})Visual feedback assumes the user is looking. Audio feedback assumes they can hear. Haptic feedback works regardless — and pairing it with the other two channels means a user who misses one cue still catches the others. That's the whole point.
What's Next
The library is at v1 and feature-complete for the platforms it targets, but there are open questions I'm chewing on:
- WebXR adapter — VR controllers expose haptics through the gamepad API extension. The adapter pattern should make this a one-file addition.
- Pattern marketplace — let designers publish, browse, and remix HPL strings. Patterns are short enough to live in a URL hash.
- HPL → CSS sync — generate matching CSS animations from a single pattern, so visual and haptic stay locked.
- ML-assisted authoring — describe a feeling in natural language, get an HPL string back. The grammar is small enough that even a tiny model could fine-tune well.
If any of these sound interesting — or if you spot a fifth platform worth supporting — issues and PRs are open on the repo. The whole point of building it as an open ecosystem was so it doesn't have to be just my project.
Try It
- Playground — thirumaleshp.github.io/hapticjs
- Docs — thirumaleshp.github.io/hapticjs/docs
- Source — github.com/thirumaleshp/hapticjs
- npm — npmjs.com/org/hapticjs