React Native SDK
The Pulsar React Native SDK (react-native-pulsar) exposes haptic feedback through three building blocks: Presets, usePatternComposer, and useRealtimeComposer. All preset functions and hook methods are worklet-compatible for use with Reanimated.
Requirements
Section titled “Requirements”- React Native 0.71+
- New Architecture enabled
Installation
Section titled “Installation”Latest available version: 1.7.0
npx expo install react-native-pulsarThen run prebuild to generate the native project files:
npx expo prebuildnpm install react-native-pulsar react-native-workletsPresets
Section titled “Presets”A collection of ready-to-use haptic patterns. All preset functions are worklet-compatible and can be called directly inside Reanimated worklets.
import { Presets } from 'react-native-pulsar';Built-in presets
Section titled “Built-in presets”| Method | Description |
|---|---|
afterglow() | A three-beat phrase that dissolves gently, ideal for soft endings or gradually quieting feedback. |
aftershock() | A firm opening that settles calmly, ideal for transitions needing a strong start and a gentle finish. |
alarm() | Relentless and urgent, best for critical errors or emergencies that require immediate attention. |
anvil() | The full weight of a massive collision, conveys sheer physical force and momentum. |
applause() | A growing wave of appreciation, ideal for celebratory moments or social approval. |
Example
import { Presets } from 'react-native-pulsar';
Presets.hammer();System presets
Section titled “System presets”Common system presets
Section titled “Common system presets”Platform-specific haptic feedback styles, common across iOS and Android.
| Method | Description |
|---|---|
impactHeavy() | UIImpactFeedbackGenerator.heavy |
impactLight() | UIImpactFeedbackGenerator.light |
impactMedium() | UIImpactFeedbackGenerator.medium |
impactRigid() | UIImpactFeedbackGenerator.rigid |
impactSoft() | UIImpactFeedbackGenerator.soft |
Example
import { Presets } from 'react-native-pulsar';import { Gesture } from 'react-native-gesture-handler';
const tap = Gesture.Tap().onEnd(() => { Presets.System.impactMedium();});Android-specific system presets
Section titled “Android-specific system presets”Additional system haptic feedback styles that are only available on Android.
| Method | Description |
|---|---|
calendarDate() | HapticFeedbackConstants.CALENDAR_DATE |
clockTick() | HapticFeedbackConstants.CLOCK_TICK |
confirm() | HapticFeedbackConstants.CONFIRM |
contextClick() | HapticFeedbackConstants.CONTEXT_CLICK |
dragCrossing() | HapticFeedbackConstants.DRAG_CROSSING |
Example
import { Presets } from 'react-native-pulsar';
Presets.System.Android.primitiveLowTick();Preset bundles
Section titled “Preset bundles”A .pulsar bundle exported from Pulsar Studio holds your own presets — each a haptic pattern with
optional synced audio and animation. Run the generator once and every preset becomes a typed member
you can call anywhere:
npx pulsar-gen-rn assetsThat scans ./assets for *.pulsar files and writes a *.bundle.ts next to each — that
generated module is what your app imports. On Expo, add the config plugin instead and it
regenerates on every prebuild:
{ "expo": { "plugins": [["react-native-pulsar", { "bundleDirs": ["assets"] }]] } }The generated module statically requires its own .pulsar, so register the extension with Metro
once:
const { withPulsar } = require('react-native-pulsar/metro');module.exports = withPulsar(getDefaultConfig(__dirname));Each generated module exports exactly two loaders:
import { loadBundleSync } from '@/assets/my-pack.bundle';
export const Haptics = loadBundleSync();Haptics.fanfare.play();Haptics.fanfare.stop();Presets are direct members of the bundle, alongside its own id, contentHash, get and
dispose. A preset can never collide with those — the generator rejects a bundle that reuses one
of those names — and only the presets are enumerable, so Object.keys(Haptics) lists the pack.
Each preset exposes play(), stop(), its id, name, duration, the raw pattern, and flags
describing how it was authored:
Haptics.fanfare.hasAudio // true if the preset has a synced soundHaptics.fanfare.hasAnimation // true if it has a Lottie animationHaptics.fanfare.animation // the Lottie itself, when the bundle carries it in JSPlaying a preset’s animation
Section titled “Playing a preset’s animation”A preset authored with an animation can drive react-native-pulsar-lottie on its own — the view
takes the animation and the haptics from the preset, with no source or haptics prop:
import { HapticLottieView } from 'react-native-pulsar-lottie';
<HapticLottieView preset={Haptics.celebration} autoPlay />loadBundleSync() is synchronous and never touches the .pulsar binary — it plays the patterns
embedded in the generated module, parsing each lazily on first play(). Preset names autocomplete
and a typo is a compile error: the types come from the generated module itself, so renaming a preset
in Studio and re-generating turns every stale reference red.
Use Haptics.get(id) when the preset id is only known at runtime; it returns undefined for an
unknown id rather than throwing.
Presets that need the .pulsar file
Section titled “Presets that need the .pulsar file”Two kinds of preset cannot be served by the embedded patterns alone: one with synced audio, and
one whose animation was exported as a dotLottie (.lottie) rather than JSON. The generator warns
about both, and hasAudio / hasAnimation identify them at runtime — such a preset still plays on
the sync path, but only its haptics.
To get the sound or the dotLottie, load the binary instead:
import { loadBundleAsync } from '@/assets/my-pack.bundle';
const bundle = await loadBundleAsync();
bundle.celebration.play(); // haptics + audioNative code reads the .pulsar before the promise settles; after that, play() is synchronous
either way. loadBundleSync(true) reads the binary on the calling thread instead — a local file
read in release, but a blocking Metro HTTP round trip in development, so prefer the async loader
unless you genuinely cannot await.
One generated module drives both paths, so the types are identical either way. Loading the same pack
twice gives two independent bundles; dispose() on one leaves the other working, and a disposed
bundle is inert rather than silently re-parsing.
Playing from a position
Section titled “Playing from a position”Pass fromMs to start a preset that far into its own timeline, so a progress bar can scrub it:
bundle.celebration.play(2500); // start 2.5s inbundle.celebration.play(); // ...and from the topThe native side re-anchors the pattern and seeks the audio to match, so both move together. On the embedded-pattern path there is no audio to move, so a seek there scrubs the haptics alone. Every non-zero seek re-parses; playing from the start reuses the cached parse.
usePatternComposer
Section titled “usePatternComposer”A React hook for composing and playing a custom Pattern. The pattern is parsed on mount and whenever it changes. Resources are released automatically on unmount.
const { play, parse, isParsed } = usePatternComposer(pattern);Parameters
Section titled “Parameters”pattern (optional)
Section titled “pattern (optional)”Type: Pattern
A haptic pattern to parse on mount. When provided, the pattern is parsed automatically and re-parsed whenever the value changes. Resources are released on unmount.
Returns
Section titled “Returns”play()
Section titled “play()”Plays the parsed pattern.
stop()
Section titled “stop()”Stops the active pattern.
parse(pattern: Pattern)
Section titled “parse(pattern: Pattern)”Parses and replaces the current pattern.
isParsed()
Section titled “isParsed()”Returns true if a pattern has been parsed and is ready to play.
Example
Section titled “Example”import { usePatternComposer } from 'react-native-pulsar';import { Gesture } from 'react-native-gesture-handler';
const pattern = { discretePattern: [ { time: 0, amplitude: 1, frequency: 0.5 }, { time: 100, amplitude: 0.5, frequency: 0.5 }, ], continuousPattern: { amplitude: [ { time: 0, value: 0 }, { time: 200, value: 1 }, { time: 400, value: 0 }, ], frequency: [ { time: 0, value: 0.3 }, { time: 400, value: 0.8 }, ], },};
function MyComponent() { const composer = usePatternComposer(pattern);
const tap = Gesture.Tap().onEnd(() => { composer.play(); });
return <GestureDetector gesture={tap}>...</GestureDetector>;}Synchronized sound
Section titled “Synchronized sound”Add a sound field to the pattern to play a short audio file in sync with the haptics. On iOS the file is registered as a Core Haptics audio event on the pattern’s timeline (sample-accurate); on Android an explicit .ogg with baked haptic channels drives the vibrator directly on supported devices, and any other file falls back to playing the audio while the pattern’s haptics fire in parallel.
const pattern = { discretePattern: [{ time: 0, amplitude: 1, frequency: 0.5 }], continuousPattern: { amplitude: [], frequency: [] }, sound: { uri: require('./beep.wav') },};
const composer = usePatternComposer(pattern);composer.play();sound.uri accepts a require('./beep.wav') (resolved automatically via Image.resolveAssetSource) or a string — a bundled resource name, an absolute path, or a file:// uri. When no extension is given it defaults to .wav, so a bare 'beep' resolves beep.wav on both platforms. Because a require() resolves to a remote Metro URL in dev but a local file in release, test require() sounds on a release build or device. See the Sound type for volume, offset, and the start/duration trim window.
useRealtimeComposer
Section titled “useRealtimeComposer”A React hook for real-time haptic control. Provides live amplitude and frequency modulation, useful for gesture-driven or continuously evolving haptic experiences. Haptics stop automatically on unmount.
const { set, playDiscrete, stop, isActive } = useRealtimeComposer();Parameters
Section titled “Parameters”useRealtimeComposer doesn’t take any parameters.
Returns
Section titled “Returns”set(amplitude: number, frequency: number)
Section titled “set(amplitude: number, frequency: number)”Updates the ongoing haptic with new amplitude and frequency values.
playDiscrete(amplitude: number, frequency: number)
Section titled “playDiscrete(amplitude: number, frequency: number)”Plays a single discrete haptic event.
stop()
Section titled “stop()”Stops the active haptic.
isActive()
Section titled “isActive()”Returns true if a haptic is currently playing.
Example
Section titled “Example”import { useRealtimeComposer } from 'react-native-pulsar';import { Gesture } from 'react-native-gesture-handler';
function MyComponent() { const realtime = useRealtimeComposer();
const pan = Gesture.Pan() .onUpdate((e) => { const amplitude = Math.min(Math.abs(e.velocityY) / 1000, 1); realtime.set(amplitude, 0.5); }) .onEnd(() => { realtime.stop(); });
return <GestureDetector gesture={pan}>...</GestureDetector>;}useAdaptiveHaptics
Section titled “useAdaptiveHaptics”A React hook that plays haptics from a cross-platform AdaptivePreset. It automatically selects the correct configuration for the current platform (iOS or Android). Each platform can provide either a custom Pattern or a native preset function.
const { play } = useAdaptiveHaptics(preset: AdaptivePreset);Parameters
Section titled “Parameters”preset: AdaptivePreset
Section titled “preset: AdaptivePreset”An object with ios and android keys. Each value is either a Pattern object or a function that triggers a native preset directly.
type AdaptivePresetConfig = (() => void) | Pattern;
type AdaptivePreset = { ios: AdaptivePresetConfig; android: AdaptivePresetConfig;};Returns
Section titled “Returns”play()
Section titled “play()”Plays the haptic for the current platform. If the platform config is a function, it is called directly. If it is a Pattern, it is played via the pattern composer.
Example
Section titled “Example”import { useAdaptiveHaptics, Presets } from 'react-native-pulsar';
const adaptivePreset = { ios: Presets.Success, // native iOS preset function android: { // custom pattern for Android discretePattern: [ { time: 0, amplitude: 1, frequency: 0.5 }, { time: 150, amplitude: 0.6, frequency: 0.4 }, ], continuousPattern: { amplitude: [], frequency: [], }, },};
function MyComponent() { const haptics = useAdaptiveHaptics(adaptivePreset);
return ( <Button onPress={haptics.play} title="Tap me" /> );}Settings
Section titled “Settings”Global configuration for the Pulsar SDK. All methods are available on the Settings object.
import { Settings } from 'react-native-pulsar';| Method | Description |
|---|---|
Settings.enableHaptics(state: boolean) | Enable or disable all haptic feedback |
Settings.enableSound(state: boolean) | Enable or disable audio simulation |
Settings.enableCache(state: boolean) | Enable or disable preset caching |
Settings.clearCache() | Clear the preset cache |
Settings.preloadPresets(presetNames: string[]) | Preload presets by name for faster playback |
Settings.stopHaptics() | Stop all currently playing haptics |
Settings.shutDownEngine() | Shut down the haptic engine |
Settings.getHapticsSupportLevel() | Returns the device’s HapticSupport level |
Settings.getHapticCapabilities() | Returns the individual HapticCapabilities behind that level |
Settings.forceHapticsSupportLevel(level) | (Android only) Override the detected support level. Intended for testing/debugging fallback behavior. |
Settings.enableImpulseCompositionMode(state: boolean) | (Android only) Enable or disable impulse composition mode |
Settings.setRealtimeComposerStrategy(strategy) | (Android only) Set the strategy used by the realtime composer |
Example
Section titled “Example”import { Settings } from 'react-native-pulsar';
// Preload frequently used presetsSettings.preloadPresets(['Fanfare', 'Explosion', 'Heartbeat']);
// Disable haptics temporarilySettings.enableHaptics(false);
// Check device supportconst support = Settings.getHapticsSupportLevel();HapticSupport
Section titled “HapticSupport”The haptic capability level of the current device, returned by Settings.getHapticsSupportLevel().
import { HapticSupport } from 'react-native-pulsar';enum HapticSupport { NO_SUPPORT = 0, LIMITED_SUPPORT = 1, STANDARD_SUPPORT = 2, ADVANCED_SUPPORT = 3,}On Android, Settings.forceHapticsSupportLevel(level) uses these exact numeric enum values when selecting the fallback path. If you force a mode while validating custom patterns, make sure you pass the exported HapticSupport enum rather than app-local aliases.
Example
Section titled “Example”import { Settings, HapticSupport, Presets } from 'react-native-pulsar';
const support = Settings.getHapticsSupportLevel();
if (support >= HapticSupport.STANDARD_SUPPORT) { Presets.dogBark();}HapticCapabilities
Section titled “HapticCapabilities”HapticSupport collapses the hardware into one ordered level, which is the right check for
“how rich a pattern can this device play”. Settings.getHapticCapabilities() returns the
individual platform capabilities behind that level, for the cases where the level is not
enough.
import { Settings } from 'react-native-pulsar';
const caps = Settings.getHapticCapabilities();| Field | Description |
|---|---|
hasAmplitudeControl | Amplitude (intensity) control, rather than on/off buzzing only |
hasPrimitiveSupport | VibrationEffect.Composition primitives (Android API 30+) |
isEnvelopeSupported | Envelope effects (Android API 36+) |
isFrequencyProfileSupported | A vendor frequency profile is available (Android API 36+) |
minControlPointDurationMillis | Shortest envelope control point the device will honor, in ms |
The support level is decided by amplitude control (and, on Android 16+, the frequency
profile). Primitive support is not part of it, so a STANDARD_SUPPORT device can still
lack composition primitives. If you render your own single-hit patterns, check
hasPrimitiveSupport and fall back to a waveform rather than composing primitives the
vibrator will silently drop.
On iOS, Core Haptics exposes a single supportsHaptics capability and the Taptic Engine
renders the full event set uniformly, so hasAmplitudeControl, hasPrimitiveSupport and
isEnvelopeSupported all mirror it; isFrequencyProfileSupported is false and
minControlPointDurationMillis is 0.
Example
Section titled “Example”import { Settings } from 'react-native-pulsar';
const { hasPrimitiveSupport } = Settings.getHapticCapabilities();
if (!hasPrimitiveSupport) { Settings.enableImpulseCompositionMode(false);}Pattern
Section titled “Pattern”Describes a complete haptic pattern with discrete pulses and continuous envelope curves.
type Pattern = { discretePattern: Array<{ time: number; // Milliseconds from pattern start amplitude: number; // Intensity (0-1) frequency: number; // Sharpness (0-1) }>; continuousPattern: { amplitude: Array<{ time: number; // Milliseconds from pattern start value: number; // Amplitude value (0-1) }>; frequency: Array<{ time: number; // Milliseconds from pattern start value: number; // Frequency value (0-1) }>; }; sound?: Sound; // Optional audio played in sync with the haptics};Use discretePattern for distinct taps and impacts. Use continuousPattern envelopes to shape a sustained haptic over time. Add an optional sound to play an audio file in sync — see Synchronized sound.
A short sound played in sync with a haptic pattern.
type Sound = { uri: number | string; // require('./beep.wav') or a resource name / path / file:// uri volume?: number; // Playback volume (0-1), defaults to 1 offset?: number; // Audio delay relative to haptics, in ms, defaults to 0 start?: number; // Where playback begins in the source file, in ms, defaults to 0 duration?: number; // How much to play from `start`, in ms, defaults to 0 (to the end)};When uri has no extension it defaults to .wav, so a bare 'beep' resolves beep.wav (iOS app bundle / Android res/raw). On Android, an explicit .ogg with baked haptic channels enables audio-coupled sync; every other format uses the audio + generated-haptics fallback.
start and duration trim the clip: start is where playback begins in the source file and duration is how much of it to play (0 plays to the end). The native side slices the audio to that window before playing rather than seeking at runtime, so a single bundled file can back several trimmed sounds without losing sample-accurate sync.
HapticSupport
Section titled “HapticSupport”enum HapticSupport { NO_SUPPORT = 0, LIMITED_SUPPORT = 1, STANDARD_SUPPORT = 2, ADVANCED_SUPPORT = 3,}RealtimeComposerStrategy
Section titled “RealtimeComposerStrategy”Android-only. Controls how useRealtimeComposer simulates continuous haptics, since Android has no native continuous haptic API. Pass one of these values to Settings.setRealtimeComposerStrategy().
import { RealtimeComposerStrategy } from 'react-native-pulsar';enum RealtimeComposerStrategy { ENVELOPE = 0, PRIMITIVE_TICK = 1, PRIMITIVE_COMPLEX = 2, ENVELOPE_WITH_DISCRETE_PRIMITIVES = 3,}| Value | Description |
|---|---|
ENVELOPE | Approximation based on the Envelope API. Allows control over amplitude and frequency, but the signal oscillates and can be unstable. Available on Android API 36+. |
PRIMITIVE_TICK | Approximation using the Composition API TICK primitive at varying intervals. Amplitude is controllable; frequency is simulated by the timing between ticks. Signal is discrete rather than continuous. |
PRIMITIVE_COMPLEX | Similar to PRIMITIVE_TICK, but uses multiple primitives depending on the requested frequency. |
ENVELOPE_WITH_DISCRETE_PRIMITIVES | Default. Hybrid strategy. Uses the Envelope API for continuous events (API 36+) and composition primitives for discrete events (API 33+). Best of both worlds when both event types are used. |
Example
import { Settings, RealtimeComposerStrategy } from 'react-native-pulsar';
Settings.setRealtimeComposerStrategy(RealtimeComposerStrategy.PRIMITIVE_COMPLEX);Testing with Jest
Section titled “Testing with Jest”react-native-pulsar is backed by a TurboModule and relies on react-native-worklets. In a Jest environment there is no native module to bind to, so importing the library directly throws (TurboModuleRegistry.getEnforcing('RNPulsar') cannot find the native module). To keep tests for components that use Pulsar running on Node, the package ships a ready-to-use mock.
Ready-to-use mock
Section titled “Ready-to-use mock”The mock lives at react-native-pulsar/jest-mock. It replaces the entire public API — Presets, Settings, the hooks, and the enums — with no-op jest.fn() implementations, so nothing tries to reach a native module. You don’t have to enumerate the 150+ presets yourself: every preset (at any nesting depth, e.g. Presets.System.Android.effectClick) is a jest.fn() you can assert on.
Wire it up with jest.mock, either per test file or globally for the whole suite.
Add this at the top of any test file that imports react-native-pulsar:
jest.mock('react-native-pulsar', () => require('react-native-pulsar/jest-mock'));Register the mock once in a Jest setup file so it applies to every test.
jest.mock('react-native-pulsar', () => require('react-native-pulsar/jest-mock'));Then reference that file from your Jest config:
module.exports = { preset: 'react-native', setupFiles: ['<rootDir>/jest.setup.js'],};Asserting that haptics fired
Section titled “Asserting that haptics fired”Because every export is a jest.fn(), you can assert that your component triggered the haptic you expect:
import { render, fireEvent } from '@testing-library/react-native';import { Presets } from 'react-native-pulsar';import LikeButton from '../LikeButton';
jest.mock('react-native-pulsar', () => require('react-native-pulsar/jest-mock'));
it('plays a success haptic when liked', () => { const { getByRole } = render(<LikeButton />);
fireEvent.press(getByRole('button'));
expect(Presets.System.notificationSuccess).toHaveBeenCalledTimes(1);});The hooks are mocked too. useRealtimeComposer, usePatternComposer, and useAdaptiveHaptics each return an object whose methods (play, stop, set, …) are jest.fn()s, so components that call them render without errors.
Customizing return values
Section titled “Customizing return values”The mock ships with sensible defaults — for example Settings.getHapticsSupportLevel() returns HapticSupport.ADVANCED_SUPPORT, and isActive() / isParsed() return false. Override any of them in a test when you need to exercise a different code path:
import { Settings, HapticSupport } from 'react-native-pulsar';
jest.mock('react-native-pulsar', () => require('react-native-pulsar/jest-mock'));
it('falls back when the device has no haptics', () => { (Settings.getHapticsSupportLevel as jest.Mock).mockReturnValue( HapticSupport.NO_SUPPORT );
// ...assert your fallback UI / behavior});Call jest.clearAllMocks() in afterEach (or set clearMocks: true in your Jest config) to reset call counts between tests.