npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

scribble-forest

v0.1.0

Published

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

Readme

scribble-forest

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 .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

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.

Scattering a field

The 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 }.

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.

Licence

MIT