Compose Stylus
Pressure-sensitive stylus / pen input for Kotlin Multiplatform and Compose Multiplatform.
compose-stylus exposes a unified PenEvent API across Desktop (JVM), Android, iOS, and Web (wasmJs).
On Desktop it taps native pen events through a small JNI layer (Cocoa / X11+XInput2 / Windows RTS) — all
other targets read pressure, tilt, and rotation directly from the platform pointer APIs.
Targets
| Target | Source |
|---|
| Desktop (JVM) | Native JNI: Cocoa (macOS), X11 + XInput2 (Linux), RTS (Windows) |
| Android | MotionEvent axes (TOOL_TYPE_STYLUS, pressure, tilt, orientation) |
| iOS | UITouch (force, azimuthAngle, altitudeAngle) |
| Web (wasmJs) | DOM PointerEvent (pointerType="pen", pressure, tiltX/Y) |
Installation
Artifacts are published to Maven Central under com.mohamedrejeb.stylus.
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
dependencies {
implementation("com.mohamedrejeb.stylus:stylus:<version>")
implementation("com.mohamedrejeb.stylus:stylus-compose:<version>")
}
Quick start — Compose
Most users only need the Compose integration:
import com.mohamedrejeb.stylus.compose.penInput
@Composable
fun Canvas() {
Box(
modifier = Modifier
.fillMaxSize()
.penInput(
onHover = { event -> },
onMove = { event -> },
onPress = { event -> },
onRelease = { event -> },
)
)
}
Or, if you'd rather route a single stream of events yourself:
Modifier.penInput { event ->
when (event.type) {
PenEventType.Hover ->
PenEventType.Move ->
PenEventType.Press ->
PenEventType.Release ->
}
}
PenEvent carries:
Drawing strokes with PenInkSurface
stylus-compose ships a PenInkSurface composable that handles in-progress
stylus rendering and persists finished strokes for you. It works on every
target, with a different engine per platform:
| Target | Engine |
|---|
| Android | Jetpack Ink — front-buffered , sub-frame latency, native motion prediction |
Same API across all targets:
import com.mohamedrejeb.stylus.compose.PenBrush
import com.mohamedrejeb.stylus.compose.PenInkSurface
import com.mohamedrejeb.stylus.compose.rememberPenInkState
@Composable
fun Notes() {
val state = rememberPenInkState()
PenInkSurface(
modifier = Modifier.fillMaxSize(),
state = state,
brush = PenBrush.pen(Color.Black, size = 5f),
) {
Row(Modifier.align(Alignment.TopEnd).padding(dp)) {
Button(onClick = { state.undo() }) { Text() }
Button(onClick = { state.clear() }) { Text() }
}
}
}
PenBrush exposes three stock brushes — pen(color, size), marker(color, size), and
highlighter(color, size) — plus PenBrush.Default.
The surrounding Modifier.penInput {} continues to fire alongside the
rendering engine, so subscribing to onPenEvent still gives you every
hover / move / press / release event with full pressure/tilt data.
Finished strokes are exposed as PenInkState.finishedStrokes: List<PenStroke>.
Each PenStroke carries its brush and a list of PenStrokePoints, so they
are platform-neutral data — a stroke captured on Desktop renders identically
when handed back to an Android PenInkSurface (and vice versa).
Reducing stylus latency on Desktop
Compose Desktop normally syncs draws with the display vsync, which adds
about one frame (~16 ms at 60 Hz) of latency between a pen event and the
rendered stroke. For drawing-first apps that trade-off is usually worth
flipping:
Both options bring stylus latency close to what Jetpack Ink achieves on
Android. The trade-offs:
- Screen tearing on the in-progress stroke during fast motion.
- Animation judder anywhere in the same Compose host that runs
transitions / spinners — Compose schedules recomposition assuming a
vsync-paced frame clock.
- Unbounded FPS, so the GPU keeps drawing during idle.
PenInkSurface itself does not flip this setting; it affects everything
in the same Compose host, so the call belongs to the host application.
This isn't true sub-frame latency the way Android's front-buffered
SurfaceControl is — the frame still goes through the OS compositor —
but it removes the vsync-imposed wait and is the closest equivalent
available on JVM/Skiko today.
Core API (no Compose)
If you need to attach callbacks outside Compose (e.g. to a Window, View, UIView, or HTMLElement):
val source = PenInputSource.Default
val callback = PenEventCallback { event ->
}
source.attach(callback, host)
source.detach(callback, host)
host: Any accepts the platform-specific surface:
Compose users should prefer Modifier.penInput {} from stylus-compose over calling PenInputSource directly.
Modules
Build prerequisites
JVM build itself only needs JDK 17 + Gradle. To rebuild the native Desktop library locally:
| OS | Toolchain |
|---|
| macOS | Xcode 15+ (clang + Cocoa / AppKit / IOKit) |
./gradlew build
./gradlew :stylus:build
./gradlew :stylus-jni:assembleNative
./gradlew :stylus-demo-jvm:run
./gradlew :stylus-demo-web:wasmJsBrowserDevelopmentRun
./gradlew :stylus-demo-android:installDebug
CI ships prebuilt native binaries for macos-aarch64, macos-x86_64, linux-x86_64, and windows-x86_64,
so consumers do not need a C++ toolchain — only contributors who want to rebuild the native code do.
License
Licensed under the Apache License, Version 2.0.