
Building a Capacitor Plugin From Scratch: Native Bridge in Swift & Kotlin

D. Rout
August 6, 2026 11 min read
On this page
Capacitor gives you dozens of official plugins, but sooner or later you'll hit a native API that nothing in the ecosystem covers — a proprietary BLE device, a vendor SDK, or in this case, raw accelerometer data for shake-gesture detection. When that happens, you don't need to eject to native or maintain a fork; you write a small, focused Capacitor plugin.
This tutorial walks through building capacitor-shake-detector from an empty folder to a working cross-platform plugin using the official Capacitor 8 plugin generator, with a real native bridge implemented in Swift (CoreMotion) for iOS and Kotlin (SensorManager) for Android. Along the way you'll see exactly how Capacitor's CAPPluginCall / PluginCall bridge works, how to emit events back to JavaScript, and how to keep your native logic testable and decoupled from the bridge itself.
The full, working source — TypeScript definitions, Swift implementation, Kotlin implementation, and an Angular/Ionic usage example — is on GitHub: capacitor-shake-detector.
Prerequisites
- Node.js LTS and npm 6+
- Xcode 15+ (for the iOS target) and a Mac
- Android Studio with an SDK 23+ emulator or device
- Familiarity with TypeScript and basic Swift/Kotlin syntax (you don't need to be fluent — the bridge code is short)
- `@capacitor/core` 8.x in the host app you'll eventually test against
Step 1: Scaffold the plugin with the official generator
Capacitor ships a dedicated generator for shareable plugins. Run it in an empty directory:
mkdir capacitor-shake-detector && cd capacitor-shake-detector
npm init @capacitor/plugin@latest
The CLI prompts for an npm package name, plugin ID (reverse-domain style, e.g. com.habitualcs.plugins.shakedetector), and plugin class name (ShakeDetector). It scaffolds:
capacitor-shake-detector/
├── src/ # TypeScript API: definitions.ts, index.ts, web.ts
├── ios/Sources/... # Swift Package Manager target
├── android/ # Gradle module
├── Package.swift
├── *.podspec
└── package.json
This is the exact structure used in the companion repo — the generator produces an Echo plugin by default; we're replacing that with shake detection.
Step 2: Design the shared TypeScript contract
Before touching native code, define the "contract" both platforms must satisfy. This is the single source of truth for method names, parameters, and event payloads — get this right and the native code on both sides becomes a mechanical translation.
src/definitions.ts:
import type { PluginListenerHandle } from '@capacitor/core';
export interface ShakeEventData {
acceleration: number; // peak G-force magnitude
timestamp: string; // ISO 8601 with timezone
}
export interface StartWatchingOptions {
threshold?: number; // default 2.5 (G above gravity)
debounceInterval?: number; // default 1000ms
}
export interface IsWatchingResult {
watching: boolean;
}
export interface ShakeDetectorPlugin {
startWatching(options?: StartWatchingOptions): Promise<void>;
stopWatching(): Promise<void>;
isWatching(): Promise<IsWatchingResult>;
addListener(
eventName: 'shake',
listenerFunc: (event: ShakeEventData) => void,
): Promise<PluginListenerHandle>;
removeAllListeners(): Promise<void>;
}
Following Capacitor's own plugin philosophy — unify units and prefer undefined over platform-specific "no value" sentinels — both native implementations will normalize their raw sensor units to G-force so the JS-facing number means the same thing on both platforms.
src/index.ts registers the plugin and wires in a web fallback:
import { registerPlugin } from '@capacitor/core';
import type { ShakeDetectorPlugin } from './definitions';
const ShakeDetector = registerPlugin<ShakeDetectorPlugin>('ShakeDetector', {
web: () => import('./web').then((m) => new m.ShakeDetectorWeb()),
});
export * from './definitions';
export { ShakeDetector };
Step 3: Implement the iOS bridge in Swift
Capacitor's iOS plugin guide recommends splitting a plugin into two classes: a plain NSObject implementation class holding the real logic, and a CAPPlugin subclass that only translates between CAPPluginCall and your implementation. This separation keeps the native logic unit-testable without spinning up a bridge.
ios/Sources/ShakeDetectorPlugin/ShakeDetector.swift — the pure logic, using CMMotionManager:
import Foundation
import CoreMotion
public class ShakeDetector: NSObject {
public typealias ShakeHandler = (_ magnitude: Double) -> Void
private let motionManager = CMMotionManager()
private var lastShakeTime: TimeInterval = 0
public var threshold: Double = 2.5
public var debounceInterval: TimeInterval = 1.0
public var onShake: ShakeHandler?
public func start() {
guard motionManager.isAccelerometerAvailable else { return }
motionManager.accelerometerUpdateInterval = 0.05 // 20Hz
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, _ in
guard let self = self, let data = data else { return }
let x = data.acceleration.x, y = data.acceleration.y, z = data.acceleration.z
let magnitude = sqrt(x * x + y * y + z * z)
let now = Date().timeIntervalSince1970
if magnitude > self.threshold && (now - self.lastShakeTime) > self.debounceInterval {
self.lastShakeTime = now
self.onShake?(magnitude)
}
}
}
public func stop() { motionManager.stopAccelerometerUpdates() }
public var isRunning: Bool { motionManager.isAccelerometerActive }
}
ios/Sources/ShakeDetectorPlugin/ShakeDetectorPlugin.swift — the bridge class. Method names and the pluginMethods array here are what Capacitor's runtime uses to route JS calls, and they must match src/definitions.ts exactly:
import Foundation
import Capacitor
@objc(ShakeDetectorPlugin)
public class ShakeDetectorPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "ShakeDetectorPlugin"
public let jsName = "ShakeDetector"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "startWatching", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopWatching", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "isWatching", returnType: CAPPluginReturnPromise)
]
private let implementation = ShakeDetector()
override public func load() {
implementation.onShake = { [weak self] magnitude in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self?.notifyListeners("shake", data: [
"acceleration": magnitude,
"timestamp": formatter.string(from: Date())
])
}
}
@objc func startWatching(_ call: CAPPluginCall) {
implementation.threshold = call.getDouble("threshold") ?? 2.5
implementation.debounceInterval = (call.getDouble("debounceInterval") ?? 1000) / 1000.0
implementation.start()
call.resolve()
}
@objc func stopWatching(_ call: CAPPluginCall) {
implementation.stop()
call.resolve()
}
@objc func isWatching(_ call: CAPPluginCall) {
call.resolve(["watching": implementation.isRunning])
}
}
Two details matter here: load() is Capacitor's hook for one-time setup (wiring the shake callback), and notifyListeners is how a plugin pushes an unsolicited event to JS — as opposed to call.resolve(), which only answers a single pending call.
Step 4: Implement the Android bridge in Kotlin
Capacitor generates plugins in Java by default, but the docs explicitly support converting to Kotlin — in Android Studio, right-click the generated class and use "Convert Java file to Kotlin file." We'll write it directly in Kotlin.
Same split as iOS: a plain class with the sensor logic, and a Plugin subclass with @CapacitorPlugin / @PluginMethod annotations as the bridge.
android/src/main/java/.../ShakeDetector.kt:
package com.habitualcs.plugins.shakedetector
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.sqrt
class ShakeDetector(context: Context) {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
var threshold: Double = 2.5
var debounceIntervalMs: Long = 1000
var onShake: ((Double) -> Unit)? = null
private var lastShakeTime = 0L
var isRunning = false
private set
private val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
// Normalize raw m/s^2 to G-force to match the iOS CoreMotion units
val gX = event.values[0] / SensorManager.GRAVITY_EARTH
val gY = event.values[1] / SensorManager.GRAVITY_EARTH
val gZ = event.values[2] / SensorManager.GRAVITY_EARTH
val magnitude = sqrt((gX * gX + gY * gY + gZ * gZ).toDouble())
val now = System.currentTimeMillis()
if (magnitude > threshold && (now - lastShakeTime) > debounceIntervalMs) {
lastShakeTime = now
onShake?.invoke(magnitude)
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
fun start() {
if (accelerometer == null) return
sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_GAME)
isRunning = true
}
fun stop() {
sensorManager.unregisterListener(listener)
isRunning = false
}
}
android/src/main/java/.../ShakeDetectorPlugin.kt:
package com.habitualcs.plugins.shakedetector
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.text.SimpleDateFormat
import java.util.*
@CapacitorPlugin(name = "ShakeDetector")
class ShakeDetectorPlugin : Plugin() {
private lateinit var implementation: ShakeDetector
override fun load() {
implementation = ShakeDetector(context)
implementation.onShake = { magnitude ->
val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date())
val ret = JSObject()
ret.put("acceleration", magnitude)
ret.put("timestamp", iso)
notifyListeners("shake", ret)
}
}
@PluginMethod
fun startWatching(call: PluginCall) {
implementation.threshold = call.getDouble("threshold", 2.5)!!
implementation.debounceIntervalMs = call.getDouble("debounceInterval", 1000.0)!!.toLong()
implementation.start()
call.resolve()
}
@PluginMethod
fun stopWatching(call: PluginCall) {
implementation.stop()
call.resolve()
}
@PluginMethod
fun isWatching(call: PluginCall) {
val ret = JSObject()
ret.put("watching", implementation.isRunning)
call.resolve(ret)
}
}
No AndroidManifest.xml permission entries are needed — TYPE_ACCELEROMETER is a normal, non-dangerous sensor on both platforms, so there's no permissions dance to build here (that's a good topic for a follow-up post).
Step 5: Add a web fallback
Capacitor plugins should degrade gracefully in the browser. src/web.ts implements the same contract using the standard DeviceMotionEvent API, so ionic serve and PWA builds keep working:
import { WebPlugin } from '@capacitor/core';
import type { IsWatchingResult, ShakeDetectorPlugin, StartWatchingOptions } from './definitions';
export class ShakeDetectorWeb extends WebPlugin implements ShakeDetectorPlugin {
private watching = false;
private lastShake = 0;
private threshold = 2.5;
private debounceInterval = 1000;
private handler = (e: DeviceMotionEvent) => this.handleMotion(e);
async startWatching(options?: StartWatchingOptions): Promise<void> {
this.threshold = options?.threshold ?? 2.5;
this.debounceInterval = options?.debounceInterval ?? 1000;
if (typeof DeviceMotionEvent === 'undefined') {
throw this.unavailable('DeviceMotionEvent is not supported in this browser.');
}
window.addEventListener('devicemotion', this.handler);
this.watching = true;
}
async stopWatching(): Promise<void> {
window.removeEventListener('devicemotion', this.handler);
this.watching = false;
}
async isWatching(): Promise<IsWatchingResult> {
return { watching: this.watching };
}
private handleMotion(event: DeviceMotionEvent): void {
const g = event.accelerationIncludingGravity;
if (!g || g.x === null || g.y === null || g.z === null) return;
const magnitude = Math.sqrt(g.x * g.x + g.y * g.y + g.z * g.z) / 9.81;
const now = Date.now();
if (magnitude > this.threshold && now - this.lastShake > this.debounceInterval) {
this.lastShake = now;
this.notifyListeners('shake', { acceleration: magnitude, timestamp: new Date().toISOString() });
}
}
}
Step 6: Consume the plugin from an Ionic/Angular app
Build and link it locally into a test app before publishing anything:
npm run build
npm install /path/to/capacitor-shake-detector # in your Ionic app
npx cap sync
Then use it exactly like an official plugin:
import { Component, OnInit, OnDestroy, signal } from '@angular/core';
import { ShakeDetector, ShakeEventData } from 'capacitor-shake-detector';
import type { PluginListenerHandle } from '@capacitor/core';
@Component({ /* ... */ })
export class ShakeDemoComponent implements OnInit, OnDestroy {
shakeCount = signal(0);
private handle?: PluginListenerHandle;
async ngOnInit() {
this.handle = await ShakeDetector.addListener('shake', (event: ShakeEventData) => {
this.shakeCount.update((n) => n + 1);
console.log('Shake magnitude:', event.acceleration, 'at', event.timestamp);
});
await ShakeDetector.startWatching({ threshold: 2.2, debounceInterval: 800 });
}
ngOnDestroy() {
this.handle?.remove();
}
}
Reference: bridge concepts across platforms
| Concept | iOS (Swift) | Android (Kotlin) |
|---|---|---|
| Base class | CAPPlugin + CAPBridgedPlugin |
Plugin |
| Method exposure | @objc func + entry in pluginMethods array |
@PluginMethod annotation |
| Plugin declaration | @objc(ClassName) + identifier / jsName |
@CapacitorPlugin(name = "...") |
| Call object | CAPPluginCall |
PluginCall |
| Reading args | call.getString("key"), call.getDouble("key") |
call.getString("key", default) |
| Resolving | call.resolve([:]) |
call.resolve(JSObject()) |
| Rejecting | call.reject(message, code, error) |
call.reject(message, code, exception) |
| Emitting events | self.notifyListeners("name", data: [:]) |
notifyListeners("name", jsObject) |
| Setup hook | override public func load() |
override fun load() |
| Unsupported API | call.unavailable("reason") |
call.unavailable("reason") |
| Not implementable | call.unimplemented("reason") |
call.unimplemented("reason") |
What's next
- Add a permissions flow — model a plugin that needs
checkPermissions()/requestPermissions(), following Capacitor's permission-alias pattern, for something like camera or location access. - Persist a long-running call — instead of
resolve()-and-forget, explore Capacitor'ssaveCall()/getSavedCall()pattern for plugins that stream data over time. - Publish to npm — run
npm run docgento auto-generate API docs from your TSDoc comments, thennpm publishunder the Capacitor Community org or your own scope. - Add native unit tests — wire up XCTest for the Swift
ShakeDetectorclass and Android instrumented tests for the Kotlin one, so the sensor logic stays verifiable independent of the bridge.
Further reading
- Capacitor: Creating Plugins (official v8 docs)
- Capacitor iOS Plugin Guide
- Capacitor Android Plugin Guide
- Capacitor Web/PWA Plugin Guide
- Capacitor Plugin Hooks
- Apple CoreMotion Documentation
Wrapping up
Writing a Capacitor plugin from scratch isn't much scarier than writing any other small native module — the hardest part is keeping the JS contract, Swift bridge, and Kotlin bridge honest with each other, which is exactly why starting from src/definitions.ts and treating it as the source of truth pays off. Everything covered here — the TypeScript definitions, the Swift CoreMotion implementation, the Kotlin SensorManager implementation, and the Angular usage example — is available in full in the capacitor-shake-detector GitHub repo, ready to clone, run, and adapt for your own native feature.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!