Explain · Canvases and tools for thought
Drawing a perfect stroke

perfect-freehand is Steve Ruiz’s library for the line a pen leaves. Its tagline is “Draw perfect pressure-sensitive freehand lines,” and the whole package is one idea exported as a function: getStroke takes an array of input points and returns the points of a polygon. The README describes the two steps. First it “creates a set of spline points” from the raw input, then it “creates outline points” around them. Those outline points form a closed polygon, which the library calls a , and you draw it however you like: SVG, canvas, WebGL, anything that can fill a path. The library is deliberately agnostic about rendering; it computes geometry and stops.

What makes the line look like ink is pressure. “The appearance of a stroke is effected by the pressure associated with each input point,” the README says, and by default getStroke simulates it from the distance between input points, which is to say from the speed of the hand, since a fast stroke spreads its samples apart and a slow one bunches them. Each input point can carry a third number for real pressure from a pen or stylus, defaulting to .5 when absent; to honour it, pass the pressure and set to false. The remaining options are few and their defaults are worth memorising, because they are the baseline the figure below starts from. size is the base diameter, default 8. is the effect of pressure on size, default .5. smoothing is how much to soften the edges, default .5. is how much to streamline the stroke, default .5. easing is a function applied to each point’s pressure, default the identity t => t. start and end take tapering options: a cap flag (default true), a taper distance (default 0; true means the whole length of the stroke), and their own easing. The cap has no effect once taper is above zero. Finally last, default true, says whether the stroke is complete; when it is, the end is drawn at the last input point rather than slightly behind it, so a line still being drawn can lag a little behind the pen while a finished one ends exactly where the pen stopped.

Two tips in the README tell you how the numbers combine. For a steady line of constant width, set thinning to 0. For a line that gets thinner under pressure instead of thicker, use a negative thinning. Everything else is taste, and taste is best formed by turning the knobs. Draw in the figure below with a mouse, a finger, or a pen. Move fast and then slow and watch the width follow your speed. Set thinning to 0 and the speed stops mattering. Push streamline up and the line lags your hand and smooths out the tremor; pull it to 0 and you get every jitter back. Turn simulated pressure off on a trackpad and the line goes flat, because no real pressure is arriving.

getStroke(points, { size: 8, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true })
Adjust
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

The figure renders its polygons with the function the README recommends. getSvgPathFromStroke starts with M at the first outline point, then for each point emits a Q whose control is the point itself and whose end is the midpoint to the next point, so the path is quadratic curves through successive midpoints, and closes with Z. If the outline has fewer than four points it returns an empty string. On a 2D canvas the same string goes into a Path2D. Because the outline can cross itself, the README also shows how to flatten it with the polygon-clipping package when you need a stroke without self-crossings. Beyond getStroke the package exports getStrokePoints, whose adjusted points carry a point, pressure, vector, distance, and running length (the last point’s running length is the path’s total length), and getStrokeOutlinePoints, with a StrokeOptions type. And although the algorithm was built for hand movement, “you can pass any set of points into the library’s functions,” and the README’s example of that uses Feather icons. Community ports exist in Dart, Odin, Python, and Rust, and there is a Figma plugin.

Freehand lines are one half of what makes tldraw look drawn rather than plotted. The other half is described in Engineering imperfection with draw shapes (2026). Geometric shapes get “a hand-drawn style through intentionally designed imperfections,” which “loosens up wireframes” and sits well next to freehand sketches. The constraints are stated precisely: the variety must be narrow enough that a rectangle is still obviously a rectangle, broad enough that no two are identical, and stable, so a shape keeps its particular wobble through resizing and transformation. The naive approach, calling Math.random() while rendering, produces shapes that jitter on every render. Instead each shape’s rendering must produce the same random sequence every time, so tldraw seeds a small xorshift-style generator with the shape’s unique, stable id. To get the density of real ink, each path is drawn more than once with different offsets, by modifying the seed per pass; two passes by default, “thicker and more textured than a single wobbly line.” Sharp corners are softened with quadratic Beziers, with rounding that depends on the angle (a 90 degree corner needs a lot, one near 180 needs little) and is clamped to a quarter of the segment length so short segments do not collapse. Steve ends by admitting a flaw: the offsets are normalised in x and y, so the area of possible offset is a square rather than a circle. “Seems our randomness could be more imperfect still.”

Take two things into your own work. First, separate geometry from rendering the way getStroke does: a function that returns points is testable, portable, and reusable across SVG and canvas, while a function that draws is none of those. Second, when you want something to look human, make the imperfection deterministic. Seed it with identity, not time, or the character you added will flicker away on the next render.