Self-drawing blueprint SVG plates: deterministic linework that draws itself in when it enters the viewport, develops to Prussian-blue cyanotype on hover, and runs an accent tracer along its own most meaningful line. No runtime dependencies (the Mersenne Twister core is bundled). The motion is CSS; the markup is generated once at build time.
It began as the hero-image system for davemaynard.dev, a portfolio whose design language is "Cyanotype Folio", and a cyanotype is a blueprint. Stock photography read as fake; drawing each project's actual architecture as schematic art did not. The plates have drawn that site's project heroes since July 2026; this package is the system extracted from its Python generator in September 2026, with the sixteen plates it shipped with as the gallery. Watch them draw live on davemaynard.dev.
Every plate shares one visual and motion language.
- Geometry. One
viewBox="0 0 1920 1080"(16:9), registration marks in the corners, stroke-only linework in five weights, one accent color. Everything is in user units inside the viewBox, so plates scale uniformly at any size. - Draw-in. When a plate enters the viewport it gets
.exposed, and every stroke draws itself:pathLength="1"plus astroke-dashoffsettransition, ordered by a per-element delay so the drawing explains itself as it appears: structure, then detail, then dimension, then accent. Dots scale in; coalescing dots travel home. - Develop. Hovering the mat develops the plate (on devices that hover; touch uses
press and focus). Paper deepens to Prussian blue, the linework goes pale, the accent
goes white. The interaction is the cyanotype process, and it is four custom
properties (
--paper,--ink,--ink-half,--accent) flipping. - Tracers. An accent dot rides each plate's most meaningful line via CSS
offset-pathon a 13s loop; a plate with no continuous line pings rings from its accent points instead. Tracers never start before the ink is dry: the build reads each plate's last stroke and starts its tracers after it, 0.87s apart. - One-way. Plates draw in and stay. Cyanotypes don't un-develop, and one plate behaving differently reads as a glitch.
- Reduced motion.
prefers-reduced-motion: reducegets finished plates immediately; tracers, pings and living strokes are hidden.
Sixteen plates, exported in folio order from self-drawing-svg/gallery (gallery, plus each
plate by name). They are the examples, the test fixtures, and the sources for the images here.
| Plate | Caption | |
|---|---|---|
| I | Dynamic Content | Measured drawing: a layout dimensioned from primitives. |
| II | Developer Experience | Ridgelines: sixteen builds settling from wild to calm; the white line is the 12-minute build. |
| III | Design System Migration | Three lattices, three rhythms, becoming one. |
| IV | Generative AI Content | Coalescence: noise assembles into the grid on entry; rings ping from the formed points. |
| V | A+ Content Manager | Circuit convergence: every surface routes through one platform; tracers ride the routes. |
| VI | Career Choice | Diverging rails: one origin switching toward six square terminals; one path lit. The deliberate mirror of Plate V. |
| VII | Floorigami | A tessellation mid-assembly: the laid checker, a frontier of flipped tiles (diagonals reversed), and empty guides waiting. |
| VIII | Color Speaks | The swatch wall: one chip pulled out and paired with its real-world object. |
| IX | Shaw Floors | The room, axonometric: plank floor as the hero, two walls, a dashed camera sightline for the interactive 3D scene. |
| X | FloorFit | The wizard: one path threading four gates, rejected options drifting off, terminus at the mood board. |
| XI | Sagepath | The contact sheet: proofs with film perforations, two struck through, the chosen one circled in grease pencil. |
| XII | Nova | The tape: eighteen candles under one measured price line; the tracer rides the closes. |
| XIII | Orbit | Orbital survey: seven bodies on three orbits around one ringed planet; the working orbit lit. |
| XIV | Bring the Pups | One engine, six hundred pages: a single source fans through eight city hubs into the page field; one route lit. |
| XV | davemaynard.dev | The folio spread: set type on the verso, a plate tipped into the recto. The book this site is, containing the plates it carries. |
| XVI | paintings.py | The canvas mid-painting: strokes laid around the ring in painting order; five living strokes keep painting, first in, first out. |
- Mine the subject's actual story (what converges, diverges, assembles, accelerates), not stock-photo vocabulary. The test: caption the plate with the headline fact and the drawing should make the caption obvious.
- Pick a motif no other plate uses. The folio works because no two plates share a graphic idea, only a language.
- Build it in the idiom: seeded
Randomonly, one accent,atdelays ordered so the drawing explains itself. - Give it one tracer on its most meaningful line:
trace: trueon that stroke. - Look at it before showing anyone. Build the preview and watch it draw.
Not on npm yet. Until it is:
npm install github:davemaynard/self-drawing-svgReact is an optional peer dependency, needed only for self-drawing-svg/react.
A plate is a plate() call: a key, a function that draws it, and its folio metadata.
The function is handed a pen with one method per shape; every option is named, and the
draw-in order is the at you give each stroke. Ask for a tracer on the line that matters
and it rides exactly the geometry the line was drawn from, so the two can never drift.
Registration marks go in the corners on their own.
import {plate} from 'self-drawing-svg';
export const relay = plate('relay', (p) => {
const rnd = p.random(7); // seeded: the plate is reproducible
p.rect(180, 450, 160, 180, {weight: 4, at: 0.15}); // the source, heaviest line first
p.ring(900, 300, 15, {at: 0.6}); // the hub
p.path('M340,540 C560,540 560,300 900,300', {accent: true, at: 0.9, trace: true});
for (let i = 0; i < 24; i++) { // receivers, jittered from the seed
const x = 1200 + (i % 6) * 90 + rnd.uniform(-4, 4);
const y = 200 + Math.floor(i / 6) * 120 + rnd.uniform(-4, 4);
p.rect(x, y, 30, 20, {weight: 1, at: 1.4 + i * 0.03});
p.line(915, 300, x, y + 10, {weight: 1, at: 1.2 + i * 0.03});
}
p.dot(900, 300, 4, {accent: true, at: 2.2});
}, {numeral: 'XVII', title: 'Relay', caption: 'One source, one hub, a scatter of receivers: the hub route lit.'});The pen: line(x1, y1, x2, y2, o?), poly(points, o?), path(d, o?), rect(x, y, w, h, o?),
ring(cx, cy, r, o?), dot(cx, cy, r, o?), trace(d, o?) for a tracer on a path nothing
else draws, fill(d) for a paper-colored mask, random(seed) for a CPython-compatible
Random, and raw(markup) for anything the pen can't say. Coordinates are user units in
the 0 0 1920 1080 viewBox.
Options on the stroked shapes (line, poly, path, rect, ring):
| Option | What it does | Default |
|---|---|---|
weight |
Line weight, 1 to 5 |
3 |
half |
Half-weight ink: the quieter, secondary linework | false |
faint |
Half ink at half opacity: the tone of the registration marks | false |
accent |
The plate's single accent color | false |
dashed |
Dashed stroke: hidden lines, sightlines. Fades in rather than drawing in | false |
filled |
Paper behind the stroke, masking whatever it overlaps | false |
living |
Accent ink that keeps painting on a loop once the ink is dry; at is then its offset in the loop. Takes only weight and at |
false |
at |
Draw-in delay in seconds; orders the drawing | 0 |
trace |
A tracer rides this exact geometry; true, or {delay, r} to shift it in the loop (negative starts it mid-run) or size the dot |
off |
closed (poly) |
Close the polyline back to its first point | false |
rx (rect) |
Corner radius | 0 |
Options on dot: accent, at, ping (a ring pings outward from the dot on a loop, for
plates with no continuous line to trace), and from: [dx, dy] (the dot starts that far
away and settles into place on exposure).
The third argument is the folio metadata (numeral, title, caption, all optional)
plus regmarks: false to leave the corner marks out. A plate is an image: its <svg>
carries role="img", a <title> from the title (the key when there is none) and a
<desc> from the caption, so assistive technology gets the caption and not a hundred
unnamed paths. decorative: true hides it instead.
import {build, render} from 'self-drawing-svg';
import {relay} from './relay';
const svgs = build([relay]); // { relay: '<svg class="plate-svg" …' }
const one = render(relay); // the same markup, for a single platebuild runs every plate's drawing, then injects every tracer and ping, then sets every
tracer's start from the plate's own last stroke: the ink is dry when the latest at
plus one draw-in has passed (3.4s at the least), and tracers follow at 0.87s intervals
plus whatever delay their author gave them. (buildAll is the old name for build.)
A config module default-exports { plates: PlateDef[] }:
// plates.config.mjs
import {relay} from './relay.js';
export default {plates: [relay]};self-drawing-svg build plates.config.mjs --svg public/plates --ts src/plates.generated.ts --preview preview.html--svg <dir>: one<key>.svgper plate (the default output when no flag is given, into./plates/).--ts <file>:export const plates: Record<string, string>keyed by plate, plushasPlate(slug).--preview <file>: the folio, every plate on its mat with numeral, title, caption and a replay button.
The same emitter is available programmatically from self-drawing-svg/build as
emit({plates, outDir, tsModule, previewHtml, tsHeader}), along with tsModule(),
previewHtml() and the stylesheet as a string (plateCss).
CSS. Import self-drawing-svg/plate.css once. It styles .plate and the stroke classes,
holds the draw-in and develop states, and the plate-trace / plate-ping / plate-living
keyframes. Retune from the host without editing it:
| Custom property | Default |
|---|---|
--plate-duration-draw |
1100ms |
--plate-duration-ink |
700ms |
--plate-ease |
cubic-bezier(0.22, 1, 0.36, 1) |
--plate-paper / --plate-ink / --plate-ink-half / --plate-accent |
#fbfbf9 / #22262a / #454b50 / #1a56c4 |
--plate-developed-paper / -ink / -ink-half / -accent |
#102e66 / #d6e2f4 / #9db8e2 / #f4f4f2 |
States: .plate.exposed is drawn in; .plate.developed is developed, as is any plate
inside a hovered, pressed or focused .plate-mat. To develop on a different trigger, set the
four plate-local colors under your own selector. That is all the state does:
a:hover .plate { --paper: #102e66; --ink: #d6e2f4; --ink-half: #9db8e2; --accent: #f4f4f2; }React.
import {Plate} from 'self-drawing-svg/react';
import {plates} from './plates.generated';
<a href="/work/relay" className="plate-mat">
<Plate markup={plates.relay} />
</a>Plate renders the markup and adds exposed once the plate is 35% in view
(IntersectionObserver, with a requestAnimationFrame fallback).
Vanilla.
import {exposeOnEnter, replay} from 'self-drawing-svg';
document.querySelectorAll('.plate').forEach((el) => exposeOnEnter(el, {threshold: 0.35}));
button.addEventListener('click', () => replay(plate)); // blank it and draw it in againWithout a bundler, self-drawing-svg/runtime.global.js is the same two functions as a
browser global, SelfDrawingSvg. The generated preview page uses it.
Plates are deterministic by construction: the only randomness is Random, and two builds
of the same plate are identical bytes. Random is CPython's random.Random: the Mersenne
Twister core is the reference implementation (the mersenne-twister package, bundled
into dist), seeded from an integer exactly as CPython seeds, with random() from 53 bits,
uniform, gauss with its cached second sample, and choice through the same rejection
sampling. new Random(29) and random.Random(29) produce the same stream, so a plate
sketched in Python draws the same picture here. (gauss() runs over Math.cos/sin/log,
which agree with C's libm to within an ulp rather than always exactly.)
npm run check # Biome: format and lint
npm test # builds dist/, then node:test: pen output, gallery snapshots, RNG
# against captured CPython streams, CLI, and Chrome (draw-in, develop,
# reduced motion; skipped where Chrome is unavailable)
npm run fixtures # re-snapshot the gallery after an intentional change to its output
npm run gifs -- --html <preview.html> --develop --sheet
# regenerates docs/: frames are driven through the Web Animations API
# (paused, seeked per frame, no wall-clock capture) and encoded with
# ffmpeg, so the images are byte-reproducibleMIT. The bundled Mersenne Twister is BSD-3-Clause; see NOTICE.

















