# perfect-freehand: pressure-sensitive stroke outlines for canvas and SVG

> The library turns recorded pointer points into the outline of a variable-width stroke. It is a geometry function, not a drawing surface, and the README is explicit about what it does not do.

**steveruizok/perfect-freehand** — Draw perfect pressure-sensitive freehand lines.

- Repository: https://github.com/steveruizok/perfect-freehand
- Website: https://perfectfreehand.com
- Stars: 5,745 · Forks: 212
- Language: HTML
- License: MIT
- Published: 2026-09-22 · Updated: 2026-09-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/steveruizok-perfect-freehand

## What getStroke actually returns

The package exports a single function, getStroke, and it does not draw anything. It takes an array of input points and returns an array of outline points. Those outline points form a polygon that surrounds the input path, and the README describes the result as a stroke. Rendering is left entirely to the caller, which is why the library has no canvas dependency, no DOM dependency and no opinion about frameworks.

The intended input is whatever your pointer events produced: page coordinates from mouse movement, or coordinates plus pressure from a stylus. The intended output is a point list you feed to a path builder. That division of labour is the whole design. It also explains the package's reach: the README lists community ports in Dart, Odin, Python and Rust, and the repository's own keywords include ink, signature and handwriting. The same geometry problem shows up in every language that has to paint a pen stroke.

## How spline points become outline points

The README's process description is short but specific. getStroke first creates a set of spline points based on the input points, then creates outline points from those. The GIF referenced in the README shows the three stages colour-coded: grey input points, red spline points, blue outline points. So the pipeline is input, smoothing, then offsetting.

Pressure enters at the offsetting stage. By default the function simulates pressure from the distance between input points, so a fast flick comes out thin and a slow drag comes out thick. That default is what makes the library usable with a plain mouse. When you have a real stylus, you pass pressure as the third number of each point and set simulatePressure to false. The README notes that pressure defaults to 0.5 when omitted, and that object-form points are converted to array-form internally, which the README itself flags as a performance cost. That note is unusually candid: the author tells you to fork getStrokeOutlinePoints and swap [0], [1], [2] for .x, .y, .pressure if object points matter to you.

Two options change the shape at the ends rather than along the body. The start and end objects carry tapering settings, and the last boolean controls whether the line's end is drawn at the final input point or slightly behind it. The README states that when last is true the end is drawn at the last input point. That is the difference between a stroke that keeps up with the cursor and one that trails it.

## Installing perfect-freehand from npm and drawing a first stroke

The README gives the install command for npm and the yarn equivalent. Both pull the published package, which is the same one the repository publishes from packages/perfect-freehand via npm publish.

```bash
npm install perfect-freehand
```

There is no initialisation step and no configuration file. You import getStroke and call it. The README's minimal example builds a list of two-element arrays and passes them in:

```js
import { getStroke } from 'perfect-freehand'

const inputPoints = [
  [0, 0],
  [10, 5],
  [20, 8],
]

const outlinePoints = getStroke(inputPoints)
```

The returned outlinePoints array is not a path string. To render it in SVG you need a helper that turns the point list into path data; the README's React example imports getSvgPathFromStroke from a local utils file, so that conversion is your code, not the library's.

A more realistic first use is wiring pointer events to state. The README's React example captures the pointer on down, appends [e.pageX, e.pageY, e.pressure] on move, and calls getStroke with explicit options:

```jsx
const stroke = getStroke(points, {
  size: 16,
  thinning: 0.5,
  smoothing: 0.5,
  streamline: 0.5,
})

const pathData = getSvgPathFromStroke(stroke)
```

The svg element in that example sets touchAction to 'none', which is what stops the browser from scrolling the page while the user draws on a touch device. For a TypeScript implementation the README points at the example project inside the repository rather than repeating the types. The repository also ships a tutorial directory and a packages/dev workspace, and the root package.json runs both through lazy run start.

## Where the options table runs out

The documented options are size, thinning, smoothing, streamline, simulatePressure, easing, start, end and last. Their defaults are 8, .5, .5, .5, true, the identity function, empty objects and true respectively. That is a small surface, and it is worth understanding what is absent from it.

There is no option for the stroke's colour, fill rule, line join or cap geometry beyond the start and end cap booleans. There is no closed-path mode, no eraser, no hit testing and no way to ask whether a given point lies inside the stroke. The README does not document rollback, undo, layering or serialisation, because the library has no state to roll back. If your editor needs any of those, you are building them on top.

The options that do exist interact in ways the table does not explain. smoothing softens the stroke's edges and streamline reduces jitter along the path, and both default to .5, so a naive call already applies half of each. Raising thinning makes pressure matter more; with simulatePressure on, that means small changes in pointer speed produce large changes in width. The easing option is the escape hatch here: it is applied to each point's pressure, so you can compress the width range without touching the geometry code. The README documents the signature as t => t and nothing about what values t takes, so you will be reading the source to pick a curve.

## Why it is the wrong tool for some drawing features

perfect-freehand produces geometry for one stroke at a time. Nothing in the README suggests it accumulates strokes, tracks which stroke is selected, or maintains a document model. An app that needs an undo stack has to store the input points itself, because the outline points are a derived value you can regenerate at any time. Storing outlines instead of inputs would be a mistake: they are larger and they bake in the current option values, so changing size or thinning later would not affect strokes already committed.

The other boundary is rendering cost. Every pointer move in the README's example calls getStroke over the entire point array and rebuilds the path data. That is fine for a short signature and increasingly wasteful for a long scribble, and the library offers no incremental API to append to an existing outline. The README does not describe a partial-stroke mode, so the usual workaround is to recompute only the tail and keep the committed prefix, which is application code.

Finally, the object-point conversion the README warns about is a real trap for React users, since object points are the natural shape for state. If you keep points as { x, y, pressure } and call getStroke on every frame, you pay the conversion each time.

## perfect-freehand against a raw polyline

The obvious alternative is not another library but the browser itself: draw a series of line segments or a quadratic curve through the pointer positions and set stroke-width to a constant. That approach is simpler, has no dependency, and is often good enough for a highlighter or a chart annotation.

The difference is what the two produce. A polyline has one width for the whole path, so a signature drawn with a mouse looks like a wire. perfect-freehand returns a filled polygon whose width varies per point, which is why it needs a fill rather than a stroke in the renderer. It also means the output is resolution-independent geometry you can transform, scale or export, rather than a rasterised line. The cost is that you now own the fill, the path conversion and the pressure data. If you have no pressure input and no interest in variable width, the plain polyline is less machinery for the same visual result. The README's own demo and Figma plugin exist to show the difference; the Figma community plugin link is the fastest way to see it without writing code.

## Maintenance, licence and the upgrade surface

The repository is not archived, and its last push was on 2026-04-13. The most recent release listed is v1.2.3 from 2026-02-01. There is a CLAUDE.md file at the repository root and a todo.md, and the toolchain is pinned: yarn@4.12.0 as packageManager, node >=20.0.0 in engines, TypeScript ^5.7.0, vitest ^3.0.0 and eslint ^9.19.0 among the dev dependencies. The root package.json is private and carries version 1.1.0, which will not match the published package version you install; the published artefact comes from the packages/perfect-freehand workspace.

Upgrade cost is low by design. The public surface is one function and an options object, and the community ports listed in the README mean a breaking change in the geometry has to be reconciled across Dart, Odin, Python and Rust implementations too. The repository runs vitest for tests and vitest bench for benchmarks, and the build script compiles the packages before the dev workspace.

The licence is MIT, declared in package.json and present as a LICENSE file at the root. That permits commercial use and modification, with the usual requirement to keep the copyright notice. It says nothing about the community ports, which are separate projects with their own licences, and nothing about the Figma plugin. Treat the ports as third-party code when you evaluate them.

## Conclusion

Adopt perfect-freehand if you already have a drawing surface (SVG, canvas, WebGL, a Figma plugin) and need the geometry of a variable-width stroke rather than a fixed-width polyline. Do not adopt it if you expect a complete drawing component with undo, layers, hit testing or persistence; the README documents none of those. Before committing, verify the options table against your own pressure data, because the default simulatePressure: true ignores the third coordinate of every input point, and confirm how your renderer handles a polygon with a hole when you close a stroke with last: false.

## FAQ

### What does it mean to draw freehand?

In this library the phrase describes drawing without a fixed geometric shape: you record pointer positions as they arrive and let getStroke derive the outline of the resulting line. The README's example collects pageX, pageY and pressure from pointer events and passes them straight to getStroke.

### What are the two main types of freehand drawing?

The README does not classify freehand drawing into types. What it does distinguish is simulated pressure, derived from the distance between input points, and real pressure, supplied as the third number of each point with simulatePressure set to false.

### Does freehand mean no reference?

The README does not discuss references or reference images. Its scope is narrower: converting an array of recorded points into an array of outline points that form a stroke polygon.

### What is the meaning of free hand?

The README uses freehand to describe pressure-sensitive lines generated from pointer input rather than from predefined shapes. The package's own keywords include ink, signature, handwriting and drawing, which is the sense the name carries here.

## Sources

- [License: MIT](https://github.com/steveruizok/perfect-freehand/blob/main/LICENSE)
- [Project website](https://perfectfreehand.com)
- [README](https://github.com/steveruizok/perfect-freehand/blob/main/README.md)
- [Releases](https://github.com/steveruizok/perfect-freehand/releases)
- [steveruizok/perfect-freehand on GitHub](https://github.com/steveruizok/perfect-freehand)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/steveruizok-perfect-freehand
