perfect-freehand

Draw perfect pressure-sensitive freehand lines from an array of input points, with no rendering dependencies.

Library
npm
v1.2.3
5,704stars
MIT License

Repository Health

Pre-computed score based on development activity, maintenance, community, maturity, and trend momentum.How we score it →
44/100Fair
Development Activity8
Maintenance20
Community48
Maturity60
Momentum40

Technical Analysis

AI-assessed by reading the actual repository — architecture, code quality, innovation, and documentation.How we score it →
76/100Good
Architecture80
Code Quality85
Innovation75
Learning Curve65

perfect-freehand is a small, dependency-free TypeScript library that turns an array of raw input points — from a mouse, touch, or stylus — into the outline points of a natural-looking, pressure-sensitive stroke. Its main export, getStroke, accepts points as either [x, y, pressure] tuples or {x, y, pressure} objects and returns a polygon (as Vec2[]) that you render however you like: as an SVG path, an HTML Canvas Path2D, or any other 2D graphics target.

Internally the library is split into two composable stages — getStrokePoints, which adjusts, streamlines, and measures the input points, and getStrokeOutlinePoints, which walks pressure-derived radii around the centerline to build the final outline — both of which are also exported individually for advanced use. Options let you tune the stroke’s base size, pressure thinning, edge smoothing, streamlining, and independent tapering/cap behavior at the start and end of the line, plus optional pressure simulation for devices (like a plain mouse) that don’t report real pressure.

It’s become the de facto stroke-rendering engine behind whiteboard and freehand-drawing tools in the JavaScript ecosystem (including tldraw, from the same author), and has been ported to Dart, Python, Rust, and Odin by the community.

What You Get

  • A single getStroke(points, options) function that returns outline points ready to be turned into an SVG path or Canvas Path2D
  • Support for both array-tuple ([x, y, pressure]) and object ({x, y, pressure}) point formats, with pressure optional in both
  • Automatic pressure simulation from point velocity for input devices that don’t report real pressure
  • Independently configurable tapering, easing, and caps for the start and end of a stroke
  • Lower-level getStrokePoints and getStrokeOutlinePoints exports for building custom pipelines on top of the core algorithm
  • Zero runtime dependencies and dual ESM/CJS builds with full TypeScript types

Common Use Cases

  • Whiteboard and freehand-drawing canvases that need natural-looking ink from mouse, touch, or stylus input
  • Signature capture for e-signature or form-signing widgets
  • Freehand annotation layers over documents, images, or diagrams
  • Custom illustration or sketching tools built on SVG or HTML Canvas

Under The Hood

Architecture The library lives inside a Yarn workspaces monorepo as packages/perfect-freehand (the published package) alongside packages/dev, a Vite-based example app. The core is a simple three-stage functional pipeline with no classes or shared mutable state: getStroke() (in getStroke.ts) is a thin composition of getStrokePoints(), which adjusts raw input into StrokePoint[] objects carrying computed vectors, distances, and running length, and getStrokeOutlinePoints(), which walks pressure-derived radii around the centerline to build the final outline polygon, handling tapering, caps, and sharp-corner detection along the way. Supporting modules (vec.ts for 2D vector math, getStrokeRadius.ts for pressure-to-radius conversion, constants.ts for tunables) are pure and dependency-free. Because getStrokePoints and getStrokeOutlinePoints are exported independently of getStroke, the StrokePoint shape is a de facto public contract — changing it would ripple through both stages and any consumer building a custom pipeline on top of them.

Tech Stack Written entirely in TypeScript with zero runtime dependencies. The library is built with Rolldown for dual ESM/CJS output plus a separate tsc pass to emit declaration files, tested with Vitest (including a dedicated vitest bench benchmark suite), and linted with ESLint via typescript-eslint and eslint-config-prettier. The surrounding monorepo uses Yarn 4 workspaces with the lazyrepo task runner to orchestrate the library build and the packages/dev example app, plus Husky and lint-staged for pre-commit formatting/linting and commitlint for conventional-commit messages.

Code Quality Each core function has a corresponding Vitest spec with snapshot assertions (getStroke.spec.ts, getStrokePoints.spec.ts, getStrokeOutlinePoints.spec.ts, getStrokeRadius.spec.ts), exercised against fixture inputs covering edge cases like a single point, two points, duplicate points, and larger point sets, plus a seeded RNG for reproducible randomized inputs. Types are used consistently throughout (StrokeOptions, StrokePoint, Vec2) with no loose typing observed in the sampled files. CI runs the build and test suite across Node 20.x and 22.x and runs a separate dependency security audit job on every push and pull request.

What Makes It Unique Rather than approximating a variable-width line with filled circles or naive quadratic segments, perfect-freehand computes an explicit outline polygon by tracking pressure-derived radii and running length on both sides of the stroke centerline, with configurable tapering and easing independently at each end and optional pressure simulation from velocity for devices with no real pressure input. It deliberately returns raw outline points rather than SVG or Canvas calls, keeping rendering entirely up to the consumer — a specific design choice that has let it become a shared stroke engine reused across SVG-, canvas-, and native-rendering drawing tools, including community ports to Dart, Python, Rust, and Odin.

Join founders buildingwith open source

Opinionated takes, migration guides, cost-saving tips, and insights from the open source ecosystem.

Subscribe on Substack
Join 750+ subscribers

Search