Skip to content

Repository files navigation

self-drawing-svg

CI

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.

Plate XIII, Orbit, drawing itself in

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.

The folio: sixteen plates, one idiom

The idiom

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 a stroke-dashoffset transition, 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-path on 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: reduce gets finished plates immediately; tracers, pings and living strokes are hidden.

Develop on hover

The gallery

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.
I, Dynamic Content
I. Dynamic Content
II, Developer Experience
II. Developer Experience
III, Design System Migration
III. Design System Migration
IV, Generative AI Content
IV. Generative AI Content
V, A+ Content Manager
V. A+ Content Manager
VI, Career Choice
VI. Career Choice
VII, Floorigami
VII. Floorigami
VIII, Color Speaks
VIII. Color Speaks
IX, Shaw Floors
IX. Shaw Floors
X, FloorFit
X. FloorFit
XI, Sagepath
XI. Sagepath
XII, Nova
XII. Nova
XIII, Orbit
XIII. Orbit
XIV, Bring the Pups
XIV. Bring the Pups
XV, davemaynard.dev
XV. davemaynard.dev
XVI, paintings.py
XVI. paintings.py

Adding a plate that belongs in the folio

  1. 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.
  2. Pick a motif no other plate uses. The folio works because no two plates share a graphic idea, only a language.
  3. Build it in the idiom: seeded Random only, one accent, at delays ordered so the drawing explains itself.
  4. Give it one tracer on its most meaningful line: trace: true on that stroke.
  5. Look at it before showing anyone. Build the preview and watch it draw.

Install

Not on npm yet. Until it is:

npm install github:davemaynard/self-drawing-svg

React is an optional peer dependency, needed only for self-drawing-svg/react.

Authoring a plate

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.

Build

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 plate

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

The CLI

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>.svg per plate (the default output when no flag is given, into ./plates/).
  • --ts <file>: export const plates: Record<string, string> keyed by plate, plus hasPlate(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).

The runtime

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 again

Without a bundler, self-drawing-svg/runtime.global.js is the same two functions as a browser global, SelfDrawingSvg. The generated preview page uses it.

Determinism

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

Development

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

License

MIT. The bundled Mersenne Twister is BSD-3-Clause; see NOTICE.

About

Self-drawing blueprint SVG plates: deterministic linework that draws in on view, develops to cyanotype on hover, and traces its own most meaningful line.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages