Guide
If you prefer reading method signatures over prose, the API reference has you covered.
While working on a different side project I had the need to draw reactive small blueprints and technical drawings. First thing I thought of, of course, was SVG: it’s math-based so it’s crisp at any zoom level, it’s powerful, and is CPU-driven so I didn’t have to scaffold a whole GPU setup.
Based on my instinct, working with plain SVG tags was going to become very messy very quickly, so I started working on a
simple wrapper to give myself easier APIs. I liked the idea of chaining commands to build a shape since the beginning so I
went with it. Initially I only built a handful of wrappers like lineTo and circle which were simple abstractions over
<line> and <circle> tags.
I wanted the freedom and flexibility to be able to build anything in a simple way, and although a line is a primitive, a circle is not, and I can’t keep adding abstractions for every shape or polygon.
But that was a small slip on my end, I immediately went back to my style which is more in favor of composable tools over opinionated abstractions.
The biggest realization though, that happened almost at the same time, was that I didn’t really need <line>, <circle>
and all these different tags, all I needed was <path>. Turns out the SVG 2 spec itself defines every basic shape as
mathematically equivalent to a path, they’re wrappers on <path> all the way down.
I created a very basic class that exposed primitives like moveTo, lineTo and arcTo and things started to get very
interesting. At this point I was hooked in.
The potential was pretty obvious to me so I decided to create an npm library out of this project, with proper care and polish in every aspect because if I needed it someone else might need it too :)
There were many iterations over the weeks but the core design didnt change (almost) at all: an engine with scheduled rendering and batching, event-based communication between systems, a path and dimensions builders, hatching, a “camera” system to handle scale and pan, graph paper background and a filter for an imperfect look. I kept refining it, always keeping simplicity, readability, and no hidden magic as priorities.
Let’s draw a rectangle
Install and import the stylesheet:
npm install pluton-2d
import "pluton-2d/style.css";
import { Pluton2D } from "pluton-2d";
const svg = document.querySelector("svg")!;
const scene = new Pluton2D(svg, {
params: { width: 220, height: 140 }, // reactive custom params
});
The SVG element needs a measurable size when the scene initializes. CSS width/height, a viewBox attribute, or an
explicit viewBox in the constructor options all work. Resolution order:
- Constructor
viewBoxoption - SVG
viewBoxattribute svg.getBoundingClientRect()size
You can also pass it explicitly:
const scene = new Pluton2D(svg, {
params: { width: 220, height: 140 },
viewBox: { width: 400, height: 300 }, // <- your drawing area size
});
Now, to actually draw something you need two things: a group and a draw callback. Groups own the SVG elements. The draw callback describes what to render each frame.
const geometryGroup = scene.geometry.group();
scene.draw((p) => {
const { width, height } = p;
const rectangle = geometryGroup.path();
rectangle
.moveToAbs(-width / 2, -height / 2) // move to the bottom left corner
.lineTo(0, height) // draw a line upwards of size `height`
.lineTo(width, 0) // draw a line to the right of size `width`
.lineTo(0, -height) // draw a line downwards of size `height`
.close(); // close the path which will connect to the initial point
});
One thing that matters here: create groups outside the callback, request builders (like geometryGroup.path()) inside it.
Groups persist across frames and reuse their SVG elements internally. If you create a group inside .draw(), you get fresh
DOM nodes every frame, which will pile up.
.close() is optional. Use it for closed shapes. Leave it off for open polylines, leaders, or guide lines or simply close
the shape yourself, that’s also valid.
Now a circle
Since everything is path-based, a circle is (usually) two half-circle arcs stitched together:
scene.draw((p) => {
const { radius } = p;
const circle = geometryGroup.path();
circle
.moveToAbs(-radius, 0)
.arcTo(radius * 2, 0, radius, true) // draw the upper semi-circle clockwise
.arcTo(-radius * 2, 0, radius, true) // draw the lower semi-circle clockwise
.close(); // optional, the circle is basically closed, this finalizes the path
});
arcTo(dx, dy, r, clockwise) draws an arc from the current point to a relative offset, with a given radius and direction.
Two arcs of 180 degrees each make a full circle.
No built-in circle() here. Like I mentioned earlier, I don’t want to keep adding shape abstractions when paths can do it all.
Two* arcs and you’ve got a circle.
*Since I also mentioned that a circle is “usually” built with two arcs, I meant it, but you can do it with 100 and…you can kind of do it with one as well:
scene.draw((p) => {
const { radius } = p;
const circle = geometryGroup.path();
circle
.moveToAbs(-radius, 0)
.arcTo(0, -Number.EPSILON, radius, true, true) // draw a large arc from where we are to almost where we are, clockwise
.close();
});
arcTo(dx, dy, r, clockwise, largeArc) the last param in this API simply tells the arc that it will exceed 180 degrees.
My personal advice is to use at least two arcs because it’s much more robust, don’t be lazy :)
Something more complex
For freeform curves, there are two options:
quadTo(cdx, cdy, dx, dy) draws a quadratic Bezier with one control point. Good for smooth bends.
cubicTo(c1dx, c1dy, c2dx, c2dy, dx, dy) draws a cubic Bezier with two control points. It gives you more control over the
shape of the curve, especially for S-curves or inflections.
scene.draw(() => {
const profile = geometryGroup.path({ className: "profile" });
profile
.moveToAbs(-120, -20)
.lineTo(80, 0)
.quadTo(40, 40, 0, 80)
.cubicTo(-30, 30, -90, 30, -120, -60)
.close();
});
The difference is just how many control points you get. Quadratic curves are simpler and work well for arcs and rounded corners. Cubic curves handle more complex profiles where the entry and exit tangents need to be independent.
Coordinate system
Pluton uses center-origin, Y-up coordinates:
- Origin at viewport center
- +X to the right
- +Y up
So lineTo(0, 50) moves up, not down. This matches the convention in engineering and technical drawing, where Y-up is the norm.
Internally, a scale(1, -1) transform flips the Y axis for SVG rendering (SVG natively uses Y-down). Arc directions are
adjusted so clockwise = true matches visual clockwise behavior despite the flip.
Reactive params
The params object you pass at construction is wrapped in a proxy. Mutating any property schedules a redraw automatically:
scene.params.width = 260;
Object.assign(scene.params, { height: 180, width: 260 });
I went with flat, proxy-backed reactivity for a reason. Deep reactivity (nested objects, arrays of objects) add real complexity to track correctly, and for the kind of state technical drawings need (a handful of numeric values), it’s overkill. Flat primitives keep the proxy simple and the redraw trigger predictable.
Constraints:
- Top-level reassignment throws:
scene.params = { ... }is blocked - Nested objects throw at construction:
params: { shape: { w: 10 } }is rejected - Params must be flat primitives
If your app state is complex, keep it outside and map flat values into scene.params.
Dimensions
Geometry and dimensions live on separate layers. The geometry layer handles the shapes themselves. The dimensions layer handles annotation: arrows, ticks, text labels, the kind of markup you see on a blueprint.
const dimensionsGroup = scene.dimensions.group();
scene.draw((p) => {
const { width, height } = p;
const dimensions = dimensionsGroup.dimension();
dimensions
.moveToAbs(-width / 2, -height / 2 - 24) // move at the bottom left corner with a slight offset downwards
.arrow(Math.PI) // create a left facing arrow
.lineTo(width, 0) // draw a horizontal line of size `width`
.arrow(0) // create a right facing arrow
.textAt(-width / 2, -10, `${width} mm`); // text at the center of the dimension with a slight offset downwards
});
The separation exists because annotations have different rendering needs (text orientation, marker sizing) and different visual styling.
Endpoint types:
arrow(angle)for open arrowheadsarrowFilled(angle)for filled arrowheadstick(angle)for tick marks
The reason for multiple endpoint types: mechanical drawings conventionally use arrows, while civil and architectural drawings use ticks. Having both means one pipeline covers both conventions without any workarounds.
Worth noting that angles follow the mathematical convention with 0 being on the right and rotation going counter-clockwise.
Camera
Pan and zoom are opt-in:
scene.enablePan(true);
scene.enableZoom(true);
scene.resetCamera();
Purposely no mobile support, for now at least :)
Controls:
- Pan: middle mouse, or shift + left click drag
- Zoom: mouse wheel, clamped to 1x-20x
- Reset: smooth interpolation back to origin
scene.setViewScale(scale) is a separate visual multiplier on top of camera zoom. It scales the rendered output without
changing world coordinates, useful for responsive sizing:
if (window.innerWidth <= 768) scene.setViewScale(0.75);
Styling
Visuals are controlled by CSS variables on .pluton-root:
.pluton-root {
--pluton-grid-minor-stroke: rgba(0, 0, 0, 0.025);
--pluton-grid-major-stroke: rgba(0, 0, 0, 0.12);
--pluton-grid-stroke-width: 0.5;
--pluton-axis-color: rgba(0, 0, 0, 0.2);
--pluton-axis-stroke-width: 1;
--pluton-axis-dash: 5 5;
--pluton-geometry-stroke: rgba(0, 0, 0, 0.7);
--pluton-geometry-stroke-width: 1;
--pluton-hatch-color: rgba(0, 39, 50, 0.14);
--pluton-dim-color: black;
--pluton-dim-stroke-width: 1;
--pluton-dim-text-color: rgba(0, 0, 0, 0.6);
--pluton-dim-font-size: 12px;
--pluton-dim-font-family: system-ui, sans-serif;
}
Custom CSS classes can be applied to paths and dimensions:
geometryGroup.path({ className: "highlighted" });
dimensionsGroup.dimension({ className: "secondary-dim" });
Hatch fill
scene.addHatchFill(color, opacity?) creates a hatch pattern and returns a url(#id) string for use as a path fill. Results
are cached per color-opacity pair.
const steelFill = scene.addHatchFill("#475569", 0.18);
scene.draw(() => {
geometryGroup
.path({ fill: steelFill })
.moveToAbs(-80, -50)
.lineTo(160, 0)
.lineTo(0, 100)
.lineTo(-160, 0)
.close();
});
When fills are enabled (default), paths without an explicit fill option get the default hatch. Use fill: "none" for
stroke-only geometry.
Filters
scene.enableFilter(true) applies a hand-drawn filter to geometry and dimensions.
It combines:
- displacement wobble (
setDisplacementScale,setDisplacementFrequency,setDisplacementOctaves) - an incomplete-line mask (
setMaskFrequency,setMaskOctaves,setMaskScale,enableMask) applied per geometry group; dimension strokes are also masked
On Safari, this filter can be expensive during zoom interactions. If frame rate drops, reduce intensity or disable the filter while navigating.
Scene controls
scene.enableFilter(true); // hand-drawn filter, default: false
scene.setDisplacementScale(2.75); // default: 2.75
scene.setDisplacementFrequency(0.1); // default: 0.1
scene.setDisplacementOctaves(1); // default: 1
scene.setMaskFrequency(0.03); // mask frequency, default: 0.03
scene.setMaskOctaves(1); // default: 1
scene.setMaskScale(1.6); // mask density, default: 1.6
scene.enableMask(false); // default: false
scene.enableFill(true); // hatch fills, default: true
scene.enableGrid(true); // background grid, default: true
scene.enableAxes(true); // origin axes, default: true
Static vs dynamic groups
By default, groups are dynamic: they commit new SVG attributes every frame. For geometry that never changes, you can mark a group as static:
const backgroundGroup = scene.geometry.group();
backgroundGroup.setDrawUsage("static");
const foregroundGroup = scene.geometry.group();
scene.draw((p) => {
const { width, height } = p;
// drawn only once, doesnt re-render even if params change
backgroundGroup
.path()
.moveToAbs(0, 0) // optional, starting point is 0,0 anyways but this makes it obvious
.lineTo(width * 2, 0)
.lineTo(0, height * 2)
.close();
// re-renders correctly as we tweak width and height
foregroundGroup
.path()
.moveToAbs(0, 0)
.lineTo(width, 0)
.lineTo(0, height)
.close();
});
Static groups still run through draw callbacks but skip DOM writes after the first commit. It’s a simple optimization: if you have a complex background that’s purely decorative, mark it static and the engine won’t diff or write its attributes on subsequent frames.
How the draw loop works
This section is for the curious. You don’t need it to use the library, but it helps to know what’s happening under the hood.
The render loop is event-driven and frame-capped at 60 FPS:
- A param mutation calls
scheduleRender(), which sets a dirty flag - The engine loop runs on
requestAnimationFrame - If the dirty flag is set and enough time has elapsed, a commit begins
- All geometry/dimensions layers call
beginRecord(), resetting their active indexes - All draw callbacks run in registration order
- Layers call
commit(), diffing attributes and writing only what changed
Batching is built in. If you write multiple params before the next frame, they collapse into a single commit:
scene.params.width = 300;
scene.params.height = 200;
// only one redraw happens
Unchanged paths skip attribute writes entirely. Extra paths left over from previous frames get pruned.
Camera smoothing runs as a separate per-frame tick. It can keep the loop alive even when no params have changed, so pan/zoom animations stay fluid.
Common pitfalls
- Creating groups inside
drawcauses DOM growth every frame - Nested params are rejected at construction
- Top-level
params = { ... }reassignment throws setViewScaledoes not change world coordinates- SVG filters during Safari zoom can drop frames
- Forgetting
scene.dispose()when tearing down scenes
When to use
Pluton fits a specific niche: technical SVG output with annotation primitives and predictable DOM updates.
When it might be a good fit:
- Technical drawings, blueprints, engineering diagrams
- Annotation-heavy scenes (dimensions, ticks, callouts)
- Workflows where inspectable or exportable SVG matters
- Interactive scenes with moderate redraw frequency
Charting libraries are better when:
- Your main goal is data visualization
- You need scales, legends, and chart conventions out of the box
- You are building standard chart types (time series, bars, scatter)
Canvas is better when:
- You need high-frequency animation
- You have many moving primitives per frame
- SVG/DOM update cost becomes the bottleneck
WebGL/WebGPU is better when:
- You need GPU-heavy effects or shader pipelines
- You need very large geometry counts
- You need 3D or large real-time scenes