Skip to content

Repository files navigation

scribble-forest

Procedurally generated pencil-scribble trees, rocks and grass, as SVG path data.

Six drawings building themselves stroke by stroke, from the ground up

Every drawing is built from a seed, so the same integer always gives back the same tree. Nothing repeats unless you want it to, and nothing has to be drawn by hand.

  • No dependencies. Not one.
  • No renderer. It returns path data, so it works with React, Vue, Svelte, plain DOM, canvas, or Node writing .svg files to disk.
  • Strokes, not fills. Every drawing is line work, so it can be animated stroke by stroke, recoloured with currentColor, or scaled without the linework thickening.

Install

npm install scribble-forest

What you get back

Generators return plain data. There is no DOM, no canvas, no framework:

import { scribbleConifer } from "scribble-forest";

scribbleConifer(42);
// {
//   width: 60,
//   height: 100,
//   strokes: [
//     { d: "M30 100Q29.4 88.2 30.6 2.8", w: 0.75, o: 0.95 },
//     ...
//   ]
// }

d is an SVG path. w is stroke width in px. o is opacity. The drawing lives in its own width × height box with the ground at y = height, so drawings of different kinds line up on a shared baseline when you scale them to different sizes.

Turning that into pixels is your job, and it is short.

Plain DOM

import { scribbleConifer } from "scribble-forest";

const tree = scribbleConifer(42);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${tree.width} ${tree.height}`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");

for (const stroke of tree.strokes) {
  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  path.setAttribute("d", stroke.d);
  path.setAttribute("stroke-width", String(stroke.w));
  path.setAttribute("opacity", String(stroke.o));
  svg.append(path);
}
document.body.append(svg);

React

import { scribbleConifer } from "scribble-forest";

function Tree({ seed, height = 100 }) {
  const tree = scribbleConifer(seed);
  return (
    <svg
      viewBox={`0 0 ${tree.width} ${tree.height}`}
      height={height}
      width={(height * tree.width) / tree.height}
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {tree.strokes.map((stroke, i) => (
        <path
          key={i}
          d={stroke.d}
          strokeWidth={stroke.w}
          opacity={stroke.o}
          vectorEffect="non-scaling-stroke"
        />
      ))}
    </svg>
  );
}

Node, straight to a file

import { writeFileSync } from "node:fs";
import { scribbleConifer, toSvg } from "scribble-forest";

writeFileSync("tree.svg", toSvg(scribbleConifer(42), { height: 400, stroke: "#2a2419" }));

toSvg is a convenience for the simple case. For anything more involved, map over strokes yourself.

Generators

Function Draws Box
scribbleConifer(seed, detail?) A pine: tiered branches, a long leader spike, scrub at the foot 60 × 100
scribbleBroadleaf(seed, detail?) A tangled crown on a short trunk 60 × 100
scribbleRock(seed, detail?) One or two faceted stones with hatched shading 48 × 32
scribbleGrass(seed, detail?) A tuft of fanned blades 44 × 50

detail runs 0 to 1 and defaults to 1. It scales stroke count with the size you intend to draw at — a dense scribble shrunk to 40px is just a smudge, so pass a lower number for small or distant drawings.

scribbleConifer(42, 1).strokes.length;    // ~40 strokes
scribbleConifer(42, 0.25).strokes.length; // ~14 strokes

Consecutive seeds, nothing else varied:

Eight conifers, eight broadleaves, and a row of rocks and grass tufts, each from a different seed

Drawing order

Strokes come back in ground-up order: ground scrub first, then the trunk rising out of it, then branch tiers from the lowest upward. Animate them in order and a tree draws itself as if growing.

The trunk paths also start at the ground and end at the tip, so an SVG line-drawing animation travels upward rather than down.

// Each stroke draws itself on, one after another.
<path
  d={stroke.d}
  pathLength={1}
  style={{
    strokeDasharray: 1,
    strokeDashoffset: 1,
    animation: "draw 1100ms ease-out forwards",
    animationDelay: `${i * 55}ms`,
  }}
/>
// @keyframes draw { to { stroke-dashoffset: 0 } }

pathLength={1} normalises every path to a single dash, so one pair of keyframes draws any stroke regardless of its real length.

One caveat if you also use vector-effect="non-scaling-stroke" (worth it, since it keeps line weight constant across sizes): it makes the browser measure dashes in screen pixels, which defeats the pathLength normalisation as soon as the drawing is scaled. Below 1× it still works, because the dash ends up longer than the path. Above 1× it breaks — at 2.5× a stroke-dasharray of 1 covers only 40% of the path, and the stroke is partly visible before it should be. Measure the real length instead:

const scale = svg.getBoundingClientRect().height / svg.viewBox.baseVal.height;
const length = path.getTotalLength() * scale;
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length; // animate to 0

Scattering a field

The package also ships the placement maths for laying drawings out as a receding field.

A receding field of scribbled trees with rocks and grass between them, smaller and fainter towards the top

import { nextPlacement, depthFactor, heightForDepth } from "scribble-forest";

const placed = [];
for (let id = 0; id < 60; id++) {
  placed.push(nextPlacement(placed, Math.random, id, "tree"));
}
// → { id, kind, xPct, yPct, heightPx, flipped, seed }

Two things worth knowing:

Vertical position doubles as depth. Something lower in the field is nearer, so it comes back taller (heightPx) and you will usually want to draw it darker. depthFactor(yPct) gives you 0 at the far edge and 1 at the near edge, which is a good input for both opacity and detail.

Placement is best-candidate, not random. Each call throws 14 darts and keeps whichever lands furthest from everything already placed. That fills gaps first and packs tighter as the field fills, instead of clumping the way uniform random does.

Pass only same-kind neighbours in the first argument. A rock belongs at the foot of a tree, so clutter should be spaced off itself rather than pushed away from the trees:

const siblings = placed.filter((p) => p.kind === kind);
nextPlacement(siblings, Math.random, id, kind);

Defaults put things slightly past the container on every side (DEFAULT_BOUNDS), so drawings that overrun the edge read as a stand continuing beyond the frame. Clip the container. Override with { bounds, heights, candidates }.

Determinism

mulberry32(seed) is exported if you want reproducible fields rather than reproducible single drawings:

import { mulberry32, nextPlacement } from "scribble-forest";

const rng = mulberry32(1234);
// Same seed in, same forest out, every time.

Generator output is stable for a given seed within a major version. Treat a changed silhouette as a breaking change; it will not happen in a patch release.

Regenerating the images

Every image above is rendered from the built package, so it can never drift from what the library actually produces.

npm run build
npm run docs:render   # writes docs/*.html

Open those pages in a browser and screenshot them. scripts/capture-gif.mjs documents how the GIF was captured; it needs Playwright and ffmpeg, which are deliberately not dependencies of this package.

Licence

MIT

About

Procedurally generated pencil-scribble trees, rocks and grass as SVG path data. Seeded, dependency-free, renderer-agnostic.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages