# Drawing a perfect stroke · Canvases and tools for thought

<!-- https://learn-ui.com/chapters/canvases/perfect-stroke -->

### One function, one polygon

[perfect-freehand](https://github.com/steveruizok/perfect-freehand) is Steve Ruiz’s library for the line a pen leaves: “Draw perfect pressure-sensitive freehand lines.” The package is one function, `getStroke`, which takes input points and returns the points of a polygon. Per the README it “creates a set of spline points” from the input, then “creates outline points” around them; the closed polygon is a **stroke** (perfect-freehand: the outline points returned by getStroke form a polygon, called a stroke, that surrounds the input points.), and you fill it with SVG, canvas, WebGL, anything. The library computes geometry and stops.

> Figure. TypeScriptperfect-freehand README (defaults)

### Pressure and the knobs

Ink comes from pressure. “The appearance of a stroke is effected by the pressure associated with each input point,” and by default getStroke simulates it from the distance between points, which is the speed of the hand. A point’s third number is real pressure, defaulting to .5; to honour a pen, pass it and set **simulatePressure** (perfect-freehand option: whether to simulate pressure based on velocity. Default true.) to false.

> Figure. TypeScriptperfect-freehand README

The defaults are the figure’s baseline. `size` is the base diameter, 8. **thinning** (perfect-freehand option: the effect of pressure on the stroke's size. Default .5.) is pressure’s effect on size, `smoothing` softens the edges, and **streamline** (perfect-freehand option: how much to streamline the stroke. Default .5.) streamlines the stroke, each .5. `easing` maps each pressure, default `t => t`.

`start` and `end` take a `cap` (default true), a `taper` distance (default 0; `true` means the whole length, and cap has no effect once taper is above zero), and their own easing. `last`, default true, draws the end at the last input point rather than slightly behind it.

Two README tips: thinning 0 gives a steady line; negative thinning gets thinner under pressure. The rest is taste, so turn the knobs below with a mouse, finger, or pen. Move fast then slow and the width follows. Push streamline up and the line lags your hand and loses the tremor; at 0 every jitter returns. Turn simulated pressure off on a trackpad and the line goes flat, because no real pressure is arriving.

> Figure 1. A pressure-sensitive stroke. Draw, then change thinning and streamline and watch the same input become a different line. Thinning 0 gives a steady line; a negative thinning thins under pressure instead of thickening.Constants from the source

### From outline to path

The figure renders with the README’s `getSvgPathFromStroke`: `M` at the first point, a `Q` through the second to the midpoint of the second and third, `T` through each later midpoint, then `Z`; fewer than four points returns an empty string. On a 2D canvas the string goes into a `Path2D`.

> Figure. JavaScriptperfect-freehand README

The outline can cross itself, so the README shows flattening with polygon-clipping. It also exports `getStrokePoints` (point, pressure, vector, distance, running length; the last point’s running length is the total), `getStrokeOutlinePoints`, and `StrokeOptions`. Built for hand movement, but “you can pass any set of points into the library’s functions,” as the Feather icons example shows. Ports exist in Dart, Odin, Python, and Rust, plus a Figma plugin.

Try it

Draw a long loop with thinning at .5 and simulation on, then again at -0.5. The relationship between speed and width inverts.

### Engineering imperfection

Freehand lines are half of what makes tldraw look drawn rather than plotted. The other half is [Engineering imperfection with draw shapes](https://tldraw.dev/blog/engineering-imperfection-with-draw-shapes) (2026): geometric shapes get “a hand-drawn style through intentionally designed imperfections,” which “loosens up wireframes.” The variety must be narrow enough that a rectangle is still a rectangle, broad enough that no two match, and stable through resizing and transformation.

`Math.random()` at render time jitters on every render, so tldraw seeds a small xorshift-style generator with each shape’s stable id. For ink density each path is drawn in two passes by default, the seed modified per pass, “thicker and more textured than a single wobbly line.”

Corners are softened with quadratic Beziers, rounded by angle (a 90 degree corner needs a lot, one near 180 little) and clamped to a quarter of the segment length. Steve admits the offsets are normalised in x and y, so the offset area is a square, not a circle: “Seems our randomness could be more imperfect still.”

Take two things. Separate geometry from rendering as getStroke does: a function that returns points is testable and portable across SVG and canvas. And make imperfection deterministic, seeded with identity rather than time, or the character flickers away on the next render.
