Drawing a perfect stroke
One function, one polygon#
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, and you fill it with SVG, canvas, WebGL, anything. The library computes geometry and stops.
import { getStroke } from 'perfect-freehand'const outline = getStroke(points, { size: 8, thinning: 0.5, smoothing: 0.5, streamline: 0.5,})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 to false.
const inputPoints = [ [0, 0, 0.5], [10, 5, 0.7], [20, 8, 0.8],]const outlinePoints = getStroke(inputPoints, { simulatePressure: false,})The defaults are the figure’s baseline. size is the base diameter, 8. thinning is pressure’s effect on size, smoothing softens the edges, and streamline 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.
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.
const average = (a, b) => (a + b) / 2function getSvgPathFromStroke(points, closed = true) { const len = points.length if (len < 4) return `` let a = points[0] let b = points[1] const c = points[2] let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( 2 )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( b[1], c[1] ).toFixed(2)} T` for (let i = 2, max = len - 1; i < max; i++) { a = points[i] b = points[i + 1] result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( 2 )} ` } if (closed) result += 'Z' return result}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.
Engineering imperfection#
Freehand lines are half of what makes tldraw look drawn rather than plotted. The other half is 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.