the engine
The DOM engine calls your update function in one shared loop. You map sampler values to HTML properties such as height, opacity or position. The Guide lists its API.
Mapping values to properties
A wave is a function: pass a number in, get a number back. Your update function maps that value to a bar's scaleY, a cell's opacity, a character index or an SVG point. The engine calls it for each visible marked element.
You describe an element type once. Then any number of elements marked data-wv="<type>" come alive - driven by the same loop, each with its own sampler and its own phase.
One registered ascii type, one sampler, redrawn each frame into a single <pre>.
One shared loop, capped at 30 fps
One requestAnimationFrame loop walks a Map of registered elements and calls each visible element's update. The loop is capped at 30 fps.
// conceptually, the shared frame: function frame(now) { requestAnimationFrame(frame); if (now - lastFrame < FRAME_MS) return; // 30 fps cap const dt = Math.min(now - prevNow, 100); // no jump after a tab switch items.forEach(it => { if (!it.visible || !it.update) return; // skip paused elements it.t += (dt / 600) * it.speed; // each element owns its clock it.update(it.state, it.t); }); }
The loop only runs while at least one element is registered, and stops itself when the last one is destroyed. Time is per-element: each starts at its own offset (derived from its seed) and advances by data-speed, so different seeds can give elements different starting offsets.
Pause what you can't see
An IntersectionObserver watches each element, with a 120 px margin around the viewport. Outside that area, the loop skips its samples and DOM updates. Scroll it back into the area and it resumes.
Try it. The tone field below is only being sampled while it's in your viewport. Scroll it off and its work stops; the shared loop keeps serving whatever is still visible.
Respect reduced motion
With prefers-reduced-motion: reduce, the engine renders one static frame and leaves the animation loop stopped. Your renderer does not need a separate reduced-motion branch.
// on init, when reduced motion is requested:
if (REDUCED) items.forEach(it => { it.t += 0.6; it.update(it.state, it.t); });
else startLoop();
The lifecycle: register → init → destroy
Three calls, on the same VanillaWaves global the maths lives on.
register(name, def)- teach the engine an element type.def.create(node, opts, h)builds the DOM and returns a state object;def.update(state, t)mutates it each frame (optional - leave it out for a static element); and an optionaldef.destroy(state, node)releases resources (timers, listeners) on teardown.init(target?)- scan for[data-wv]and wire them up. No argument does the whole document; pass a selector, element, or NodeList to wire a subtree. It auto-runs once onDOMContentLoaded, and safely skips anything already wired, so you can call it again after adding nodes.destroy(target?)- stop and clean up. The engine removes whatcreateadded (the element returns to how it started), runs your optionaldestroydisposer, restoresaria-hidden, and stops the shared loop once the last element is gone. Sodestroytheninitre-renders cleanly instead of duplicating children. Use it to stop a loader once the content has arrived.
// a loader that stops itself when the page is ready: VanillaWaves.init('#boot-loader'); window.addEventListener('load', () => { VanillaWaves.destroy('#boot-loader'); // element, selector, NodeList, or nothing = all });
Register types before the auto-init fires (i.e. in a script that runs during parse). After adding elements or types, call init() again.
Helpers
Your create function receives helpers for creating elements, reading numbers and preparing a sampler.
| Helper | Does | |
|---|---|---|
| makeSampler(extra?) | A sampler with element-friendly defaults - shift on, gentle group, low frequency, seeded from the element's data-seed. Pass extra to override (e.g. { range: [0,1] }). | |
| norm(v) | Map a wave value (≈[−1,1]) to [0,1], clamped - handy for opacity, scale, colour stops. | |
| num(v, default) | Read a data-* value as a number with a fallback. | |
| el(tag, class?) | Create an element (optionally classed). | |
| W | The library itself, if you want W.createSampler(...) with your own options instead of the element defaults. | |
Every data-* attribute on the element arrives in opts (camel-cased): data-count → opts.count, data-text → opts.text. That's how one registered type serves many configurations from markup alone.
Accessibility & seeds. Decorative elements get aria-hidden="true" automatically; for a meaningful element, opt out with data-decorative="false" (or set your own aria-hidden) and the engine leaves it be. data-seed may be any string - non-numeric seeds are hashed to a stable start offset, so they animate like numeric ones.
Build a bar renderer
The eq renderer creates bars and changes their height from sampler values. The example below uses the same shared loop and offscreen pause as the other renderers.
VanillaWaves.register('eq', {
create(node, opts, h) {
const n = h.num(opts.count, 18); // data-count, default 18
const bars = [];
for (let i = 0; i < n; i++)
bars.push(node.appendChild(h.el('span')));
return { bars, n, s: h.makeSampler() }; // state handed to update()
},
update(st, t) {
for (let i = 0; i < st.n; i++) {
const v = 0.08 + st.s.sample(i * 0.55, t) * 0.92; // wave → height
st.bars[i].style.transform = 'scaleY(' + v.toFixed(3) + ')';
}
}
});
<div data-wv="eq" data-seed="13" data-count="28"></div>
The Examples page gives you five more of these - wave line, text loader, ASCII field, tone field, ribbons - each with its full source. They all share this exact shape: create builds, update maps.
Math and engine
The engine augments the VanillaWaves global with register / init / destroy, but it never touches the maths. waves-core.js knows nothing about the DOM; engine.js knows nothing about specific elements. And concrete components - loaders, controls, draggables - live one layer up again, in vanilla.waves_elements, importing this engine.
waves-core.js // the maths - zero DOM, byte-identical to p5.waves engine.js // this: shared loop + register/init/destroy + helpers vanilla.waves.js// the two, bundled (the CDN artifact) // ── elements import the engine; the engine never imports them ──
For the math alone, load waves-core.js and drive your own loop, as in the starters. The script bundle includes the DOM engine.