Building a Capacitor Plugin From Scratch: Native Bridge in Swift & Kotlin
capacitor ionic swift kotlin mobile-development

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

D. Rout

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's saveCall() / getSavedCall() pattern for plugins that stream data over time.
  • Publish to npm — run npm run docgen to auto-generate API docs from your TSDoc comments, then npm publish under the Capacitor Community org or your own scope.
  • Add native unit tests — wire up XCTest for the Swift ShakeDetector class and Android instrumented tests for the Kotlin one, so the sensor logic stays verifiable independent of the bridge.

Further reading

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.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!