react-native-haptic-composer
v0.1.0
Published
Declarative haptic orchestration for React Native — compose iOS CoreHaptics and Android VibratorManager patterns with a tiny string DSL, presets, and a testable engine abstraction.
Maintainers
Readme
react-native-haptic-composer
Declarative haptic orchestration for React Native. Compose iOS CoreHaptics and Android VibratorManager patterns with a tiny string DSL, trigger them from one hook, and test everything with a recording mock engine — no device required.
const { trigger } = useHapticComposer();
await trigger('short-heavy-pause-100-long-light-rise'); // or trigger('heartbeat')Features
- Pattern DSL — describe timings, intensities, transient taps, pauses and intensity ramps in one readable string.
- Pure, typed parser —
parsePattern()has zero react-native imports and throws a typedHapticPatternErrorwith the offending token index. - Bundled presets —
success-celebration,heartbeat,error-crunch, plussoft-tickanddrumroll. - Engine abstraction — CoreHaptics (iOS) / VibratorManager (Android) via a
small native module, with an automatic
Vibrationfallback when the native side isn't installed (Expo Go, quick prototypes). - First-class testability — inject
MockHapticEngineto record exactly what would have played. - Zero runtime dependencies.
Install
npm install react-native-haptic-composer
# or
yarn add react-native-haptic-composerPeer dependencies: react >= 18, react-native >= 0.72.
Native setup (optional but recommended)
Out of the box the library plays patterns through React Native's Vibration
API — rhythm only. To get real intensity, sharpness and ramps, install the
native HapticComposer module from native-setup/:
- iOS (CoreHaptics) — copy
native-setup/ios/HapticComposerModule.swiftplus a small Objective-C export shim into your app; needs iOS 13+ and a device with a Taptic Engine. Full steps:native-setup/ios/README.md. - Android (VibratorManager) — copy
native-setup/android/HapticComposerModule.ktplus aReactPackage, add theVIBRATEpermission. Amplitude control needs API 26+; API 31+ usesVibratorManager. Full steps:native-setup/android/README.md.
The hook auto-detects the module: engineKind is 'core-haptics' /
'vibrator-manager' when it's installed, 'rn-vibration-fallback' otherwise.
Quickstart
import { Button } from 'react-native';
import { useHapticComposer } from 'react-native-haptic-composer';
export function LikeButton() {
const { trigger, stop, engineKind, isSupported } = useHapticComposer();
return (
<Button
title={`Like (${engineKind}${isSupported ? '' : ', unsupported'})`}
onPress={() => {
// Preset name, or any DSL string:
trigger('success-celebration').catch(console.error);
}}
onLongPress={stop}
/>
);
}A complete screen with a button per preset and a custom-pattern composer lives
in example/HapticButtonsScreen.tsx.
Pattern DSL
Patterns are dash-separated tokens, case-insensitive. A pulse is a duration
word plus an optional intensity word, in either order (short-heavy ==
heavy-short). Without an intensity word a pulse defaults to soft (0.6).
| Token | Meaning | Values |
| --- | --- | --- |
| short | continuous pulse | 60 ms, sharpness 0.8 |
| medium | continuous pulse | 150 ms, sharpness 0.5 |
| long | continuous pulse | 300 ms, sharpness 0.3 |
| tap | transient micro-pulse | 10 ms, sharpness 1.0, default intensity 1.0 |
| light | intensity for the adjacent pulse | 0.3 |
| soft | intensity for the adjacent pulse | 0.6 |
| heavy | intensity for the adjacent pulse | 1.0 |
| pause-<ms> | silence | e.g. pause-100 |
| <number> | bare-number shorthand for a pause | e.g. 120 |
| rise / fall | intensity ramp on the preceding continuous pulse | not valid on tap |
Examples:
| Pattern | Result |
| --- | --- |
| tap | one crisp 10 ms tick |
| short-heavy | 60 ms pulse at full intensity |
| light-long-rise | 300 ms gentle pulse swelling upward |
| tap-40-tap-40-tap | three ticks, 40 ms apart |
| short-heavy-pause-100-long-light-rise | heavy blip, 100 ms rest, long rising light pulse |
parsePattern(pattern) returns { steps, totalDurationMs }, where each step
carries an absolute atMs offset, durationMs, intensity, sharpness,
transient, and an optional curve. Malformed input throws
HapticPatternError with tokenIndex and token.
Presets
| Name | Pattern | Feel |
| --- | --- | --- |
| success-celebration | tap-pause-80-tap-pause-80-long-heavy-rise | two ticks, then a rising swell |
| heartbeat | medium-heavy-pause-120-medium-soft-pause-600 | lub-dub with a rest (loop-friendly) |
| error-crunch | short-heavy-pause-40-short-heavy-pause-40-long-heavy-fall | harsh double buzz dying away |
| soft-tick | tap-light | single gentle tick |
| drumroll | tap-40-tap-40-tap-40-tap-40-tap-pause-120-medium-heavy-rise | five taps into a rising accent |
trigger() resolves preset names first (case-insensitive); anything else is
parsed as DSL.
API
useHapticComposer(options?)
useHapticComposer(options?: { engine?: HapticEngine }): {
trigger(patternOrPreset: string): Promise<void>;
stop(): void;
engineKind: 'core-haptics' | 'vibrator-manager' | 'rn-vibration-fallback' | 'mock';
isSupported: boolean; // async capability check; starts false, settles after mount
}Core
parsePattern(pattern: string): HapticPattern— pure DSL parser.HapticPatternError— typed parse error (tokenIndex,token).HAPTIC_PRESETS,isHapticPresetName()— bundled presets.- Types:
HapticPattern,HapticStep,HapticCurve,HapticEngineKind,HapticPresetName.
Engines
HapticEngine—{ play(pattern): Promise<void>; stop(): void; kind; isSupported(): Promise<boolean> }.NativeHapticEngine— drivesNativeModules.HapticComposer.VibrationFallbackEngine/toVibrationPattern(pattern)— converts steps to the[wait, vibrate, ...]ms array for RNVibration.vibrate.MockHapticEngine— records playback for tests.getHapticComposerNativeModule()— shape-validated native module lookup.resolveDefaultHapticEngine()— the hook's default selection logic.
Testing with MockHapticEngine
Inject the mock through the hook (or use it directly) to assert on exactly what would have played:
import { renderHook, act } from '@testing-library/react';
import {
MockHapticEngine,
parsePattern,
useHapticComposer,
} from 'react-native-haptic-composer';
it('plays a heavy tick on save', async () => {
const engine = new MockHapticEngine();
const { result } = renderHook(() => useHapticComposer({ engine }));
await act(async () => {
await result.current.trigger('tap-heavy');
});
expect(engine.played).toHaveLength(1);
expect(engine.lastPlayed).toEqual(parsePattern('tap-heavy'));
expect(engine.stopCount).toBe(0);
});MockHapticEngine exposes played (all patterns, oldest first), lastPlayed,
stopCount, a mutable supported flag for simulating unsupported devices,
and reset() for beforeEach.
Limitations of the Vibration fallback
- Intensity, sharpness and curves are collapsed — the OS only receives on/off timings.
- On iOS,
Vibration.vibrateignores per-element durations; only the rhythm survives. Install the native module for real CoreHaptics output.
License
MIT © Dinesh Kumar
