Procedurally generated pencil-scribble trees, rocks and grass, as SVG path data.
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
.svgfiles 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.
npm install scribble-forestGenerators 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.
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);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>
);
}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.
| 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 strokesConsecutive seeds, nothing else varied:
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 0The package also ships the placement maths for laying drawings out as a receding field.
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 }.
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.
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/*.htmlOpen 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.
MIT


