DEV Community

Cover image for I Made a Stroke Stabilization Library for Web Drawing Apps
usapopopooon
usapopopooon

Posted on

I Made a Stroke Stabilization Library for Web Drawing Apps

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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()
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Calculate distance between current input and last output
  2. If distance is less than minDistance, reject the point
  3. 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

  1. Keep a buffer of the last N points
  2. 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

  1. Calculate distance from anchor point (last output) to current input
  2. If within string length: anchor doesn't move, no output
  3. 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

  1. Estimate current speed (how fast the pointer is moving)
  2. Calculate cutoff frequency based on speed
    • Slow movement → Low cutoff → Strong smoothing
    • Fast movement → High cutoff → Weak smoothing
  3. 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:

  1. Take a window of neighboring points (e.g., 5 points centered on current)
  2. Multiply each point by its weight (determined by kernel type)
  3. 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,
})
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)