Explain · Canvases and tools for thought
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 , and you fill it with SVG, canvas, WebGL, anything. The library computes geometry and stops.

TypeScriptperfect-freehand README (defaults)
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 to false.

TypeScriptperfect-freehand README
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. is pressure’s effect on size, smoothing softens the edges, and 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.

Input points0
Length0 px
Outline points0
getStroke(points, { size: 8, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true })
Adjust
Figure 2. 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. Show points to see the raw input in grey and the spline points in green under the polygon, and replay to watch the last stroke redraw at the speed you drew it.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.

JavaScriptperfect-freehand README
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.