In this hands-on lesson, we're going to create a simple module that uses native haptic feedback on iOS and Android. This is a fairly simple module, but it will lay down a strong foundation so you can keep building from here. Another goal is to spark your curiosity so you can continue experimenting with it and expanding it to be more flexible, depending on what you want to achieve.
We're going to learn how to create a module from scratch as a local module, which is the recommended approach. Then, once you're sure you want to publish it and make it available to everyone, you can extract it into a standalone package. For any new module you create, I recommend starting locally and using it in your app first, then deciding whether you want to open source it and maintain it.
Getting Started
To create a new Expo module, use the following command:
bunx create-expo-module@next cwb-haptics --localFor the latest stable version, use:
bunx create-expo-module cwb-haptics --localWhen prompted, accept the defaults for module name and Android package name.
Project Structure
After creation, your module will have this structure:
modules/
cwb-haptics/
android/ # Kotlin implementation
ios/ # Swift implementation
src/
index.ts # Main exports
CwbHaptics.types.ts # TypeScript types
CwbHapticsModule.ts # Module definition
expo-module.config.jsonKey Files Explained
TypeScript Types
Define your module's API interface:
export type CwbHapticsModuleEvents = {};
export type HapticStyle = "light" | "medium" | "heavy";
export interface CwbHapticsModule {
triggerHaptic(style: HapticStyle): Promise<void>;
}iOS Implementation (Swift)
Uses UIImpactFeedbackGenerator for haptic feedback:
import ExpoModulesCore
import UIKit
public class CwbHapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("CwbHaptics")
AsyncFunction("triggerHaptic") { (style: String) in
DispatchQueue.main.async {
let generator: UIImpactFeedbackGenerator
switch style.lowercased() {
case "light":
generator = UIImpactFeedbackGenerator(style: .light)
case "medium":
generator = UIImpactFeedbackGenerator(style: .medium)
case "heavy":
generator = UIImpactFeedbackGenerator(style: .heavy)
default:
generator = UIImpactFeedbackGenerator(style: .medium)
}
generator.prepare()
generator.impactOccurred()
}
}
}
}Android Implementation (Kotlin)
Add the vibration permission to modules/cwb-haptics/android/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>Then implement the vibration logic with waveform patterns:
package expo.modules.cwbhaptics
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class CwbHapticsModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val vibrator: Vibrator
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
override fun definition() = ModuleDefinition {
Name("CwbHaptics")
AsyncFunction("triggerHaptic") { style: String ->
val (timings, amplitudes, oldPattern) = when (style.lowercase()) {
"light" -> Triple(longArrayOf(0, 10), intArrayOf(0, 50), longArrayOf(0, 10))
"heavy" -> Triple(longArrayOf(0, 50), intArrayOf(0, 255), longArrayOf(0, 50))
else -> Triple(longArrayOf(0, 20), intArrayOf(0, 128), longArrayOf(0, 20)) // medium
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(oldPattern, -1)
}
}
}
}Usage in Your App
Import and use your module:
import { Button, StyleSheet, View } from "react-native";
import CwbHaptics from "../../modules/cwb-haptics";
export default function Index() {
return (
<View style={styles.container}>
<Button
title="Light Haptic"
onPress={async () => {
await CwbHaptics.triggerHaptic("light");
}}
/>
<Button
title="Medium Haptic"
onPress={async () => {
await CwbHaptics.triggerHaptic("medium");
}}
/>
<Button
title="Heavy Haptic"
onPress={async () => {
await CwbHaptics.triggerHaptic("heavy");
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});Development Tips
- Use Xcode for Swift code - Better syntax highlighting and autocomplete
- Use Android Studio for Kotlin code - Better development experience
- Recompile after native changes - Native code changes require full rebuild
- Test on real devices - Haptic feedback doesn't work well in simulators
- Keep modules focused - One module should do one thing well
Building and Testing
iOS
npx expo prebuild --platform ios
xed iosThen build and run from Xcode (⌘R).
Android
npx expo prebuild --platform android
# Open android folder in Android StudioOr use:
open -a "Android Studio" androidBest Practices
- Start with local modules before publishing
- Keep modules isolated and focused on one responsibility
- Test thoroughly on both platforms and real devices
- Document your module's API clearly
- Consider edge cases and API version compatibility