shapes-orbit
v0.1.0
Published
A fractal world (the Mandelbulb) with a Julia-set moon circling it, shaped by the chord being played: calm chords keep both smooth and round, tense chords grow spines and crumble the moon to dust. Ray-marched in WebGL2, no dependencies.
Maintainers
Readme
shapes-orbit
A fractal world with a fractal moon circling it, shaped by the chord being played. The world is a Mandelbulb: calm chords keep it smooth and round, tense chords grow ridges, peaks and spines. The moon is a 3D Julia set: it is round for calm chords, gets curly as they tense, and crumbles into dust for clashing ones. Louder playing speeds the moon up, and every chord pours its color in. Both are ray-marched in one WebGL2 shader, under a sky of stars. No dependencies.

It comes from Shapes, a music toy for young children (the orbit.html page there is built on this package). Its flat sibling is shapes-julia.
- Install
- Quick start
- Recipes: no bundler, React, Vue, MIDI keyboard, sound, no WebGL2, no touch
- API
- Optional extras:
/synth,/math - Performance
- How the chord picks the shapes
Install
npm install shapes-orbitIt's ES modules only, with TypeScript types included. It works with any bundler (Vite, webpack, esbuild, Rollup, Parcel), or straight from a CDN with no build step (see No bundler).
Quick start
A complete page. Give the canvas a size with CSS: the drawing follows that size.
<div style="height: 70vh"><canvas id="orbit" style="width: 100%; height: 100%; touch-action: none"></canvas></div>
<p id="caption"></p>
<script type="module">
import { createOrbit } from 'shapes-orbit';
const caption = document.getElementById('caption');
const orbit = createOrbit(document.getElementById('orbit'));
orbit.on('chord', ({ name, world, moon, color }) => {
caption.textContent = `${name}: ${world} ${moon}`; // "C major: A smooth, round world. Its moon is smooth and round."
caption.style.color = color;
});
// Whenever the notes sounding change (MIDI note numbers, 60 = middle C)…
orbit.setNotes([60, 64, 67]);
// …and whenever a note or chord is struck (strength 0–1, e.g. velocity / 127)
orbit.strike(0.8);
</script>There are two calls to know:
setNotes(notes)says what is sounding. It reshapes the world and the moon, which glide there. Call it every time a note starts or stops, passing everything still held. An empty list leaves the last shapes in place, so the picture doesn't collapse between chords.strike(strength)says when something was played. A ring of the chord's color flows in and soaks into the world, and the moon is nudged along its orbit, harder for a harder strike. Call it on each note-on (or each chord you trigger).
Try [60, 64, 67] (C major: a smooth, round world and moon), [55, 59, 62, 65] (G7: spiky peaks) and [60, 61, 62, 63] (a cluster: spines and a moon of dust).
People can drag the picture to look around (it drifts back to the usual view when they let go), and a tap emits a tap event. touch-action: none on the canvas stops a drag from scrolling the page.
Recipes
No bundler
Map the bare name to a CDN with an import map, and the Quick start page works as a static file:
<script type="importmap">
{
"imports": {
"shapes-orbit": "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js",
"shapes-orbit/synth": "https://cdn.jsdelivr.net/npm/[email protected]/dist/synth.js"
}
}
</script>Or import from the URL directly: import { createOrbit } from 'https://esm.sh/[email protected]'.
React
Create the view once the canvas exists, and destroy it when the component goes away. Hold it in a ref, so a render never makes a second view.
import { useEffect, useRef, useState } from 'react';
import { createOrbit, type OrbitView } from 'shapes-orbit';
export function Orbit({ notes, strikeKey }: { notes: number[]; strikeKey: number }) {
const canvas = useRef<HTMLCanvasElement>(null);
const view = useRef<OrbitView | null>(null);
const [caption, setCaption] = useState({ text: '', color: '' });
useEffect(() => {
const orbit = createOrbit(canvas.current!);
orbit.on('chord', ({ name, world, moon, color }) => setCaption({ text: `${name}: ${world} ${moon}`, color }));
view.current = orbit;
return () => { orbit.destroy(); view.current = null; };
}, []);
useEffect(() => { view.current?.setNotes(notes); }, [notes]);
useEffect(() => { if (strikeKey) view.current?.strike(0.8); }, [strikeKey]); // bump strikeKey to strike
return (
<>
<canvas ref={canvas} style={{ width: '100%', height: '60vh', touchAction: 'none' }} />
<p style={{ color: caption.color }}>{caption.text}</p>
</>
);
}React's Strict Mode mounts twice in development. That's fine: destroy() frees everything, and the canvas takes the new view. With server rendering (Next.js and the like), importing the package on the server is safe: nothing touches the DOM until createOrbit runs, and effects only run in the browser.
Vue
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { createOrbit, type OrbitView } from 'shapes-orbit';
const props = defineProps<{ notes: number[] }>();
const canvas = ref<HTMLCanvasElement>();
let orbit: OrbitView | undefined;
onMounted(() => { orbit = createOrbit(canvas.value!); orbit.setNotes(props.notes); });
watch(() => props.notes, notes => { orbit?.setNotes(notes); orbit?.strike(0.8); });
onBeforeUnmount(() => orbit?.destroy());
</script>
<template><canvas ref="canvas" style="width: 100%; height: 60vh; touch-action: none" /></template>Other frameworks work the same way: create in the mount hook and destroy in the unmount hook.
A MIDI keyboard
The Web MIDI API delivers note numbers that setNotes takes as they are. Keep the set of held keys, send all of it on every change, and strike on each note-on:
import { createOrbit } from 'shapes-orbit';
const orbit = createOrbit(document.getElementById('orbit'));
const held = new Set();
const access = await navigator.requestMIDIAccess();
for (const input of access.inputs.values()) {
input.onmidimessage = ({ data: [status, note, velocity] }) => {
const kind = status & 0xf0;
if (kind === 0x90 && velocity > 0) { // note on
held.add(note);
orbit.setNotes([...held]);
orbit.strike(velocity / 127);
} else if (kind === 0x80 || kind === 0x90) { // note off (or note on at velocity 0)
held.delete(note);
orbit.setNotes([...held]); // [] keeps the last shapes
}
};
}Web MIDI works in Chrome, Edge, Firefox and Chrome on Android. Safari and iOS don't have it.
When several notes of a chord land a few milliseconds apart, the view merges their rings into one, so playing chords doesn't flood the picture.
With sound
Pair it with shapes-orbit/synth, whose timbre follows the chord's tension too, and let the glow and the moon's speed follow what's actually heard. A tap on the picture can replay the chord:
import { createOrbit } from 'shapes-orbit';
import { startNote, playNotes, loudness } from 'shapes-orbit/synth';
const orbit = createOrbit(canvas, { loudness });
const releases = new Map();
const held = new Set();
let lastChord = [];
function noteOn(note, velocity) {
releases.set(note, startNote(note, velocity / 127));
held.add(note);
lastChord = [...held];
orbit.setNotes(lastChord);
orbit.strike(velocity / 127);
}
function noteOff(note) {
releases.get(note)?.();
releases.delete(note);
held.delete(note);
orbit.setNotes([...held]);
}
orbit.on('tap', () => { if (lastChord.length) { playNotes(lastChord); orbit.strike(0.8); } });When WebGL2 is missing
createOrbit doesn't throw. Check isSupported() and show something else, and do the same if the browser takes the graphics away:
import { createOrbit } from 'shapes-orbit';
const orbit = createOrbit(canvas);
const fallback = document.getElementById('no-gl'); // e.g. "This device can't draw the world and its moon"
fallback.hidden = orbit.isSupported();
orbit.on('contextlost', () => { fallback.hidden = false; });
orbit.on('contextrestored', () => { fallback.hidden = true; });The chord event still fires without WebGL2, so captions and colors keep working.
A picture only (no touch)
For a background, a thumbnail, or when your app owns the pointer, turn the drag and tap off:
import { createOrbit } from 'shapes-orbit';
const orbit = createOrbit(canvas, { interactive: false });The canvas then gets no pointer listeners at all, and needs no touch-action.
API
createOrbit(canvas, options?) → OrbitView
Each call makes its own view, so a page can hold several (see Performance first). It does not throw when WebGL2 is missing: check isSupported() and show your own fallback.
| Option | Default | |
|---|---|---|
| dark | follows prefers-color-scheme | Dark-theme paints. The sky is black in either theme. |
| reducedMotion | follows prefers-reduced-motion | No orbiting, wandering, gliding, rings or twinkling stars; changes are immediate, and drawing stops once the scene settles. |
| maxPixelRatio | 2 | The highest drawing resolution, in drawing pixels per CSS pixel. |
| minPixelRatio | 2 | The least drawing resolution before adapting: above the screen's own it supersamples, so the ink outlines and spines stay smooth on 1× screens. Never more than maxPixelRatio. |
| autoResize | true | Follow the canvas's CSS size with a ResizeObserver. Set when created; with false, call resize() yourself. |
| interactive | true | Drag to look around (it drifts back afterwards); a tap emits tap. Set when created. |
| loudness | none | () => number, 0–1, read every frame: louder is a brighter glow and a faster moon. Without it the level is what setLoudness() last gave, or each strike fading away. |
Leave dark and reducedMotion out to follow the system, which updates live. Pass them to follow your app's own setting instead.
OrbitView
| Method | |
|---|---|
| setNotes(notes: number[]) | The notes sounding now, as MIDI numbers (any octave, any order, duplicates fine). The world and moon glide to the chord's shapes; an empty list leaves them as they are. Emits chord. |
| strike(strength = 0.7, notes = last chord) | A strike: a ring of the chord's color flows in and soaks into the world, and the moon is nudged forward along its orbit. Does nothing before any chord. |
| setLoudness(value: number \| null) | Push a loudness (0–1); null goes back to following strikes. Ignored when the loudness option is given. |
| setOptions(partial) | Change options live (not autoResize or interactive). A key given as undefined goes back to its default (e.g. following the system theme again). |
| on(event, listener) → () => void | Listen; call the returned function to stop. |
| resize() | Re-measure the canvas now. |
| isSupported() | false without WebGL2, when the shader can't be built, or while the context is lost. |
| destroy() | Stop drawing, remove every listener and observer (pointer listeners included), and free the WebGL program and buffer. Safe to call twice; later calls to the view do nothing. The canvas can be given to a new createOrbit. |
Events
| Event | Payload |
|---|---|
| chord | An OrbitChord (below), after every setNotes with at least one note. |
| tap | None. A press on the picture that wasn't dragged (only with interactive). |
| contextlost | None. The browser took the graphics away (common on phones in the background); drawing stops. |
| contextrestored | None. They came back and drawing has resumed. |
A listener that throws doesn't stop the others; its error is rethrown on a microtask so it still shows up in the console.
OrbitChord
| Field | Type | |
|---|---|---|
| notes | number[] | The notes given to setNotes. |
| name | string | 'C major', 'A minor', 'G7'… or the note names when they make no chord ('C D♭ D E♭'). |
| mood | Mood \| null | 'major' \| 'minor' \| 'maj7' \| 'dom7' \| 'm7' \| 'm7b5' \| 'dim7', or null when there is no chord. |
| world | string | Words for the world, written for a child: 'A smooth, round world.', 'A world with ridges.', 'Spiky peaks and valleys.', 'A world of spines and crevices!'. |
| moon | string | Words for the moon: 'Its moon is smooth and round.', 'Its moon has wiggly edges.', 'Its moon is all curls.', 'Its moon has crumbled into dust!'. |
| color | string | The color the chord paints, as CSS rgb(r g b), ready for style.color. |
| rgb | [r, g, b] | The same color in sRGB 0–1. |
| tension | number | 0 for calm and up; the moon breaks into dust at DUST (0.73). |
| dust | boolean | Whether the moon crumbled into dust. |
Types exported: OrbitView, OrbitOptions, OrbitChord, OrbitEvents, OrbitListener, RGB.
Optional extras
shapes-orbit/synth: the matching sound
The synth voice from Shapes (the same as shapes-julia/synth), whose timbre follows the chord: round and mellow for calm chords, buzzing for tense ones, crackling for clusters. It is a single shared audio graph per page, and it follows every note it is playing by itself.
| Export | |
|---|---|
| startNote(midi: number, strength: number) → () => void | Start a held note (strength 0–1, e.g. velocity / 127). Call the returned function to release it. |
| playNotes(notes: number[]) | A quick strum that rings out by itself, as when a chord is tapped. |
| loudness() → number | How loud the output is right now, 0 to about 1, measured from the sound itself. Pass it as createOrbit's loudness option. |
| ensureAudio() → AudioContext | The shared context, created (and resumed) on first call. Call it inside a tap or key handler if you want sound ready before the first note. |
| unlock(event) | The handler the entry installs on the first pointerup, click or keydown to start audio. You won't usually need it. |
Browsers only start audio after a user gesture. Importing shapes-orbit/synth listens once for the first tap, click or key anywhere to unlock it, so this entry has side effects. The others don't. The synth plays at most 24 notes at once (the oldest fades).
shapes-orbit/math: the shapes without the drawing
The pure functions behind the picture. They have no DOM and no WebGL, so they're safe on a server, in a worker or in tests. For example, to describe a chord's scene without drawing it:
import { tension, bulbnessFor, feelOfBulb, juliaTarget, moonLine, nameChord, moodColor, toHex } from 'shapes-orbit/math';
const notes = [55, 59, 62, 65]; // G7
const { name, mood } = nameChord(notes); // 'G7', 'dom7'
feelOfBulb(bulbnessFor(tension(notes))); // 'Spiky peaks and valleys'
moonLine(juliaTarget(notes)); // 'Its moon has wiggly edges.'
if (mood) toHex(moodColor(mood)); // the chord's color as '#rrggbb' (mood is null for no chord)The world (a Mandelbulb blended from a sphere)
| Function | |
|---|---|
| powerFor(tension) → number | The bulb's power, POWER_MIN (4, lumpy) to POWER_MAX (8, the classic spiky bulb). |
| bulbnessFor(tension) → number | How much of the bulb shows, 0.15 (nearly a sphere) to 1 (the whole bulb). |
| feelOfBulb(bulbness) → string | The world words, without the final full stop. |
| bulbRadius(power, fold?) → number | A radius that holds the whole bulb. |
| bulbDistance(point, power, steps?, fold?) → number | The bulb's distance estimate at a point [x, y, z]. |
The moon (a quaternion Julia set)
| Function | |
|---|---|
| juliaTarget(notes, last?) → JuliaTarget | Where the chord sits: theta, reach (1 = the edge between island and dust), spin and dust. Ambiguous chords (augmented, °7) keep the last target's theta and spin. |
| planetC(theta, reach, tilt) → Quaternion | The moon's c, with its imaginary part tilted toward the fourth axis. |
| flatOf(c) → Point | The flat complex c whose escape behaviour the moon shares. |
| escapeRadius(c) → number | A radius that holds the whole Julia set for a flat c. |
| moonLine({ reach, dust }) → string | The moon words. |
The orbit and camera
| Function | |
|---|---|
| moonAt(angle, radius?, tilt?) → [x, y, z] | The moon's position round its orbit (ORBIT_RADIUS 2.25, tilted ORBIT_TILT −0.32 rad). |
| orbitSpeed(loudness) → number | Radians a second: 0.25 when quiet to 0.7 when loud. |
| nudgeFor(strength) → number | The extra speed a strike adds, up to 0.45 rad/s, fading over about a third of a second. |
| cameraDistance(aspect, tanHalfFov) → number | How far back the camera stands so the world and the whole orbit fit. |
| MOON_SIZE | The moon's radius in world units (0.45). |
Chords and colors: tension(notes), nameChord(notes), foldFor(tension) (how faceted, 0 to 1), DUST, and the OKLCH colors moodColor(mood, dark?), noteColor(midi, dark?) with toHex(lch) for CSS. These work exactly as in shapes-julia/math.
The shader: vertexSource and fragmentSource (GLSL ES 3.00) and UNIFORMS (every uniform name) are exported so you can read or reuse them. They're tied to the view's internal camera, orbit and color model and aren't a stable API. To draw the scene, use createOrbit.
Performance
This is a ray march through two fractals for every pixel: up to 200 steps, each working out the Mandelbulb's distance and, near the moon, a quaternion Julia set's. Give it a real GPU. On slower devices the adaptive resolution trades sharpness for frame rate, so the picture gets softer rather than slower.
- Adaptive resolution. The view draws at most 30 frames a second. When frames take longer than 40 ms on average, it steps the resolution down by 20% at a time, as far as 30% of full. It steps back up by 5% only after a long calm (5 s of quick frames, and 10 s since it last stepped down), so the picture never flickers between sizes.
maxPixelRatio: 1andminPixelRatio: 1lower the starting point for big canvases. - It only draws while something moves. Without
reducedMotionthe moon is always orbiting, so it keeps drawing at 30 fps. Destroy views you're not showing. WithreducedMotionit stops completely once the scene settles. - It pauses while the page is hidden and carries on where it was when the page comes back.
- One view per screen is the intended use. Each view has its own WebGL context and full GPU load. Two at once halve the frame budget, and browsers allow only about 16 contexts per page.
How the chord picks the shapes
The chord's tension (roughness plus instability) turns two dials on the world: the Mandelbulb's power, from 4 (lumpy) to 8 (spiky), and how much of the bulb shows through a plain sphere. Triads stay round with gentle hills, sevenths grow ridges and peaks, and clusters become a world of spines.
The moon is the 3D cousin of shapes-julia's island. The chord's pitch-class Fourier transform picks a point on the edge of the Mandelbrot set's main cardioid: the fifths component chooses where, so each key has its own moon. Tension chooses how far out the point sits: inside, the moon is one solid body; past the edge, it crumbles into dust. The thirds component tilts c into the fourth dimension, which turns the moon's shape.
Tense chords also facet both bodies into straight-edged crystal shards. Light comes from one sun, with soft shadows: the moon shades the world, and the world eclipses the moon. Cel-shaded ink outlines keep both readable against the stars.
Browser support
WebGL2: Safari 15+, Chrome/Edge 56+, Firefox 51+, Android System WebView on Android 7+. The code targets ES2020. TypeScript users need moduleResolution bundler, node16 or nodenext (or node, through typesVersions).
Changes
See CHANGELOG.md.
Licence
MIT. See LICENSE.
