- GitHub: https://github.com/usapopopooon/stroke-stabilizer
- npm: https://www.npmjs.com/package/@stroke-stabilizer/core
- Live Demo: https://usapopopooon.github.io/stroke-stabilizer/
Introduction
If you've ever built a freehand drawing app or whiteboard app, you've probably encountered the problem of jagged, shaky lines. (By "freehand" here, I mean pen tablet, mouse, or touch input.)
So I made a library called stroke-stabilizer to smooth out hand tremor.
Here's a demo. The red line is raw input, and the green line is the stabilized output.
You can adjust the stabilization level with the slider.
What Is This Library?
stroke-stabilizer smooths coordinate data from pointer events (mouse, touch, pen) to produce smoother strokes.
My motivation was wanting the "stabilization" feature found in paint software like ClipStudio Paint, but for web apps.
Most existing libraries lock you into a single smoothing algorithm. With stroke-stabilizer, you can combine multiple filters yourself. Want just noise reduction? Or both Kalman filter and string filter? You can customize it for your use case.
There might be better approaches, and some parts of the implementation may still be rough, but I've written up the details here in case it's helpful to someone.
Features
- Combine filters freely: Mix noise reduction, Kalman filter, moving average, string filter, etc. in any order to build your own stabilization pipeline
- Lightweight: Core library has zero dependencies, small bundle size
- Framework support: React/Vue wrappers available
- Real-time processing: Stabilizes while drawing
- Post-processing filters: Apply Gaussian or bilateral smoothing after stroke completion
- Endpoint correction: Automatically adjusts so strokes end at the actual pointer position
- rAF batching: Batch high-frequency pointer events into animation frames
- TypeScript support: Type definitions included
When to Use (and When Not to)
Good fit:
- Freehand drawing/painting apps
- Whiteboard and annotation tools
- Signature capture
- Any app where smooth pen input matters
Not a good fit:
- Precision drafting apps (CAD, etc.)
- Apps with existing stabilization
- Simple click-and-drag operations
Installation
Choose based on your environment:
# Core library only
npm install @stroke-stabilizer/core
# If using React (install additionally)
npm install @stroke-stabilizer/react
# If using Vue (install additionally)
npm install @stroke-stabilizer/vue
Basic Usage
Vanilla JavaScript
import {
createStabilizedPointer,
gaussianKernel,
} from '@stroke-stabilizer/core'
// Create a stabilizer (level is 0-100)
const pointer = createStabilizedPointer(50)
// Add post-processing (optional)
pointer.addPostProcess(gaussianKernel({ size: 5 }))
// Process coordinates from pointer events
canvas.addEventListener('pointermove', (e) => {
// IMPORTANT: Use getCoalescedEvents() for smoother input
const events = e.getCoalescedEvents?.() ?? [e]
for (const ce of events) {
const point = {
x: ce.offsetX,
y: ce.offsetY,
pressure: ce.pressure,
timestamp: ce.timeStamp,
}
const stabilized = pointer.process(point)
if (stabilized) {
// Draw using stabilized.x, stabilized.y
}
}
})
// Apply post-processing and get final points on stroke end
canvas.addEventListener('pointerup', () => {
const finalPoints = pointer.finish()
// Redraw with smoothed points
})
React
import {
useStabilizedPointer,
useStabilizationLevel,
} from '@stroke-stabilizer/react'
function DrawingCanvas() {
const { level, setLevel } = useStabilizationLevel({ initialLevel: 50 })
const { process, reset, pointer } = useStabilizedPointer({
level,
onPoint: (point) => {
// Draw with stabilized coordinates
},
})
const handlePointerMove = (e) => {
// IMPORTANT: Use getCoalescedEvents() for smoother input
const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]
for (const ce of events) {
process({
x: ce.offsetX,
y: ce.offsetY,
pressure: ce.pressure,
timestamp: ce.timeStamp,
})
}
}
const handlePointerUp = () => {
// Apply post-processing and get final stroke
const finalPoints = pointer.finish()
reset()
}
// ...
}
Vue
<script setup>
import {
useStabilizedPointer,
useStabilizationLevel,
} from '@stroke-stabilizer/vue'
const { level, setLevel } = useStabilizationLevel({ initialLevel: 50 })
const { process, reset, pointer } = useStabilizedPointer({
level: level.value,
onPoint: (point) => {
// Draw with stabilized coordinates
},
})
const handlePointerMove = (e) => {
// IMPORTANT: Use getCoalescedEvents() for smoother input
const events = e.getCoalescedEvents?.() ?? [e]
for (const ce of events) {
process({
x: ce.offsetX,
y: ce.offsetY,
pressure: ce.pressure,
timestamp: ce.timeStamp,
})
}
}
const handlePointerUp = () => {
// Apply post-processing and get final stroke
const finalPoints = pointer.finish()
reset()
}
</script>
Important: Using getCoalescedEvents()
This is essential for smooth strokes. Browsers throttle pointermove events to around 60fps, but pen tablets and high-refresh-rate mice can generate 200+ events per second. Without getCoalescedEvents(), you lose all those intermediate points, resulting in jagged strokes no matter how good your stabilization is.
canvas.addEventListener('pointermove', (e) => {
// Get all coalesced events (falls back to single event if unsupported)
const events = e.getCoalescedEvents?.() ?? [e]
for (const ce of events) {
pointer.process({
x: ce.offsetX,
y: ce.offsetY,
pressure: ce.pressure,
timestamp: ce.timeStamp,
})
}
})
The ?. optional chaining handles browsers that don't support getCoalescedEvents() (though most modern browsers do).
Framework notes:
-
React: Access via
e.nativeEvent.getCoalescedEvents?.() - Vue: Access directly on the event (it's already the native PointerEvent)
Algorithm Details
This section covers technical details. Feel free to skip if you just want to use the library.
Architecture: Dynamic Pipeline Pattern
This library uses a Dynamic Pipeline Pattern.
Traditional Builder or Chain of Responsibility patterns make it hard to modify a pipeline at runtime. But drawing apps often need to update filter parameters in real-time when the user moves a slider, or toggle specific filters on/off.
That's why I came up with this pattern. I wrote about it in detail in a separate article:
The "Dynamic Pipeline" Pattern: A Mutable Method Chaining for Real-time Processing
usapopopooon ・ Jan 14
Key characteristics
- Dynamic add/remove — Add or remove filters at runtime
- Parameter updates — Change specific filter settings without rebuilding the entire pipeline
-
Always ready — No
.build()call needed. Ready to use immediately - Order matters — Filters execute in the order they were added
Input → [Filter 1] → [Filter 2] → ... → [Filter N] → Output
Multiple filters are available, and combinations are flexible. For example, you could implement a system where the filter combination changes based on level (0-100):
| Level | Applied Filters |
|---|---|
| 0 | None (raw data passes through) |
| 1-20 | Noise filter |
| 21-40 | Noise + Kalman filter |
| 41-60 | Noise + Kalman + Moving average |
| 61-80 | Above + String filter (weak) |
| 81-100 | Above + String filter (strong) |
Let me explain each filter.
1. Noise Filter
The simplest filter. If the distance from the last output is below a threshold, it discards the input.
How it works
- Calculate distance between current input and last output
- If distance is less than
minDistance, reject the point - Otherwise, pass it through
In other words: "if it barely moved, ignore it." Simple, but effective at removing fine jitter from sensor noise.
2. Kalman Filter
The Kalman filter is a recursive algorithm that estimates optimal state by combining "prediction" and "observation." It's apparently used for things like rocket trajectory calculations.
This library implements a simple position-only model (no velocity) for stability with high-frequency input devices (144Hz+).
How it works
The filter maintains two values: estimated position and its uncertainty.
Prediction step:
- Assume position is the same as last time
- Increase uncertainty slightly (state might have changed)
Update step:
- Compare prediction with actual observation
- Calculate "Kalman gain" (how much to trust the observation)
- Blend prediction and observation based on gain
- Update uncertainty
Key parameters:
- Process noise (Q): How much position is expected to change between samples
- Measurement noise (R): How much to trust raw input
Roughly speaking:
- Higher R → Trust observation less → Smoother output
- Lower Q → Trust prediction more → Slower response to direction changes
3. Moving Average Filter
A simple filter that takes the average of the last N points.
How it works
- Keep a buffer of the last N points
- Output the average of all points in the buffer
This works as a low-pass filter. Larger N means smoother output but more lag.
4. String Filter (Lazy Brush)
Imagine a virtual "string" between the pen tip and the drawing point. That's basically how it works. ClipStudio Paint's stabilization probably uses a similar approach.
How it works
- Calculate distance from anchor point (last output) to current input
- If within string length: anchor doesn't move, no output
- If beyond string length: anchor gets "pulled" toward input
The anchor moves only by the amount exceeding the string length. Like pulling something on a leash.
This filter behaves naturally. Slow movements get stabilized (delayed by string length), fast movements are followed appropriately.
5. One Euro Filter
An adaptive filter that adjusts smoothing strength based on speed. Applies strong smoothing to slow movements, weak smoothing to fast movements.
Reference paper
Géry Casiez, Nicolas Roussel, and Daniel Vogel. 2012. 1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems.
https://cristal.univ-lille.fr/~casiez/1euro/
How it works
- Estimate current speed (how fast the pointer is moving)
- Calculate cutoff frequency based on speed
- Slow movement → Low cutoff → Strong smoothing
- Fast movement → High cutoff → Weak smoothing
- Apply exponential moving average with the calculated smoothing factor
Key parameters:
- minCutoff: Smoothing strength at low speed (lower = smoother)
- beta: How fast smoothing decreases as speed increases
6. Post-processing Kernels
Convolution filters applied after stroke completion. Since they process bidirectionally (looking at both past and future points), there's no phase delay like with real-time filters.
The library includes these kernel types:
- Gaussian kernel: Standard Gaussian-weighted smoothing (center-weighted with smooth falloff)
- Box kernel: Uniform weights (simple average)
- Triangle kernel: Center-weighted with linear falloff
- Bilateral kernel: Edge-preserving smoothing that respects sharp corners
How convolution works
For each point in the stroke:
- Take a window of neighboring points (e.g., 5 points centered on current)
- Multiply each point by its weight (determined by kernel type)
- Sum the weighted values to get the smoothed output
Boundary handling has three padding modes: reflect, edge (replicate endpoints), and zero (zero padding).
Endpoint preservation
By default, smooth() preserves exact start and end points after convolution. This ensures the stroke reaches the actual pointer position when you lift the pen.
import { smooth, gaussianKernel } from '@stroke-stabilizer/core'
// Default: preserve endpoints (recommended)
const smoothed = smooth(points, {
kernel: gaussianKernel({ size: 5 }),
})
// Disable endpoint preservation
const smoothedAll = smooth(points, {
kernel: gaussianKernel({ size: 5 }),
preserveEndpoints: false,
})
| Option | preserveEndpoints | Behavior |
|---|---|---|
| Default | true |
Start and end points match original exactly |
| Disabled | false |
All points affected by convolution (may drift) |
Parameter Tuning Tips
Each filter's parameters can be adjusted for your use case. Here's a rough guide:
| Filter | Parameter | Lower value | Higher value |
|---|---|---|---|
| Noise | minDistance | Picks up fine points | Ignores subtle movements |
| Kalman | processNoise (Q) | Trusts prediction, slower response | Trusts observation, faster response |
| Kalman | measurementNoise (R) | Closer to raw data | Smoother |
| Moving Average | windowSize | Faster response, more jitter | Smoother, more lag |
| String | stringLength | More responsive | More stable |
| One Euro | minCutoff | Stronger smoothing | Weaker smoothing |
| One Euro | beta | Less speed-sensitive | More speed-sensitive |
Building Custom Pipelines
The preset (createStabilizedPointer) is convenient, but if you don't need certain filters or want fine-grained parameter control, you can combine filters yourself.
Each filter has strengths and weaknesses, so try different combinations for your use case. For example, if you have lots of fast strokes, keep the string filter weak. For slow, careful drawing, make the Kalman filter stronger.
import {
StabilizedPointer,
noiseFilter,
kalmanFilter,
oneEuroFilter,
gaussianKernel,
} from '@stroke-stabilizer/core'
const pointer = new StabilizedPointer()
// Real-time filters
.addFilter(noiseFilter({ minDistance: 2 }))
.addFilter(oneEuroFilter({ minCutoff: 1.0, beta: 0.007 }))
// Post-processing
.addPostProcess(gaussianKernel({ size: 7 }))
// During drawing
const stabilized = pointer.process(point)
// On stroke end (applies post-processing)
const finalPoints = pointer.finish()
rAF Batching
High-frequency pointer events (144Hz+) can flood the event queue. The library provides optional requestAnimationFrame batching.
const pointer = new StabilizedPointer()
.addFilter(noiseFilter({ minDistance: 2 }))
.enableBatching({
onBatch: (points) => {
// Called once per frame with all processed points
drawPoints(points)
},
onPoint: (point) => {
// Called for each individual point (optional)
},
})
canvas.addEventListener('pointermove', (e) => {
// Queue instead of process - will be batched
pointer.queue({
x: e.offsetX,
y: e.offsetY,
pressure: e.pressure,
timestamp: e.timeStamp,
})
})
canvas.addEventListener('pointerup', () => {
// Flush pending points before finishing
const finalPoints = pointer.finish()
})
Closing
That's my stroke stabilization library, stroke-stabilizer.
I hope this helps someone building drawing or whiteboard apps. There might be better approaches out there, but hopefully this gives you some ideas...
Bug reports and feature requests are welcome on GitHub Issues.
- GitHub: https://github.com/usapopopooon/stroke-stabilizer
- npm: https://www.npmjs.com/package/@stroke-stabilizer/core
- Live Demo: https://usapopopooon.github.io/stroke-stabilizer/
Top comments (0)