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.

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

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

See the examples → API reference →