guide

Use wave() for a value, createSampler() for repeated samples, or the DOM engine to update HTML elements.

Install

vanilla.waves is a single file with no runtime dependencies. No npm, no bundler, no build step.

CDN (jsDelivr)

The pinned version keeps updates from changing your page unexpectedly. This script includes the math (waves-core.js) plus the DOM engine (engine.js):

<script src="https://cdn.jsdelivr.net/gh/seb-prjcts-be/vanilla.waves@v0.3.1/vanilla.waves.min.js"></script>

Local

Copy vanilla.waves.min.js (or the readable vanilla.waves.js) next to your HTML and point a <script> at it. For the math alone, load waves-core.js.

ES module

Import waves-core.mjs when using a bundler or a module-based renderer such as three.js:

import { createSampler, wave } from './waves-core.mjs';

The module works in browsers, Node and bundlers. It exposes the math; load the script bundle when you also need the DOM engine. See the module details for its exports and limits.

On the cdn. From v0.3.1 the module entry ships on jsDelivr too: import { createSampler, wave } from 'https://cdn.jsdelivr.net/gh/seb-prjcts-be/vanilla.waves@v0.3.1/waves-core.mjs'; Or copy it locally for a fully offline, zero-dependency setup.

Global. The library exposes VanillaWaves (alias vWaves, and Waves as a drop-in when p5.waves isn't present).

Your first element

Register an element type once, mark elements with data-wv="<type>". The shared loop calls your update every frame; offscreen elements pause automatically.

<div data-wv="bars" data-seed="13" data-count="20"></div>

<script src="vanilla.waves.min.js"></script>
<script>
  VanillaWaves.register('bars', {
    create(node, opts, h) {
      const n = h.num(opts.count, 16);
      const bars = [];
      for (let i = 0; i < n; i++) bars.push(node.appendChild(h.el('span')));
      return { bars, s: h.makeSampler() };        // state
    },
    update(state, t) {                             // every frame
      state.bars.forEach((b, i) => {
        const v = 0.1 + state.s.sample(i * 0.5, t) * 0.9;
        b.style.transform = 'scaleY(' + v.toFixed(3) + ')';
      });
    }
  });
  // auto-inits on DOMContentLoaded; call VanillaWaves.init() after adding nodes
</script>

Types must be registered before the engine's auto-init (DOMContentLoaded). If you register or add nodes later, call VanillaWaves.init() again - it skips elements already wired.

The math: three ways to call it

1. Default wave: wave(y)

One argument. A deterministic value from seed 0 at amplitude 100. Same input, same output.

const v = VanillaWaves.wave(x);   // amplitude 100, wave picked by seed 0

2. by name or seed

const a = VanillaWaves.wave(x, 'mountain peaks');  // by name
const b = VanillaWaves.wave(x, 42);                // by seed (deterministic pick)

3. Options object

const y = VanillaWaves.wave(x, {
  wave: 'bumpy sine',
  amplitude: 80,
  frequency: 1,
  t: performance.now() / 1000,
  phase: 0,
  seed: 7
});

Options

Every option, its default, and what it does. All are optional.

Wave, amplitude and time

OptionDefaultDescription
wavenullA name, an index (0…34), or [a, b] to morph. null → picked by seed.
amplitude100Output is scaled to [−amp, +amp].
frequency1Multiplier on x. For sine-based waves this means faster cycles; where x enters non-linearly (chirps like shake out, envelopes) it reshapes the wave rather than just speeding it up.
t0Time, in seconds. Anything that moves reads this.
seed0Picks the wave when wave is null; seeds the PRNG for harsh waves.

Phase, range, mix and group

OptionDefaultDescription
phase0Added to x before evaluation. Shifts the wave horizontally.
rangenull[lo, hi] - map output to this range instead of amplitude scaling. Use for opacity 0–1, hue 0–360, etc.
mix0.5Morph position (0…1) when two waves are set.
group'all''gentle', 'harsh', 'closing', 'ghost' (curated closing subset that stays clean when you plot a wave against a delayed copy of itself), 'all', or an array of names. Restricts which waves seed/shift can pick.

Shift and wild mode

OptionDefaultDescription
shiftfalseAuto-cycle through the group, smoothly morphing between waves.
shiftInterval3Seconds each wave is held.
shiftDuration1Seconds to morph between consecutive waves.
mode'stable''wild' enables noise modulation on top of the wave.
unpredictability00…1, wild only. 0 = clean, 1 = chaotic.

Repeated sampling with createSampler()

Calling wave(x, opts) hundreds of times per frame re-resolves the wave, group, and stats every call. createSampler() resolves all of that once and provides a reusable sample().

const s = VanillaWaves.createSampler({
  shift: true,
  group: 'gentle',
  amplitude: 120
});

// per frame:
const v = s.sample(x, t);   // position + time

Use sample(y, t) when shift or time-based motion is on; sample(y) when t doesn't matter. Live getters: waveName, targetName, shifting, mix, period, targetPeriod.

Engine API

The DOM engine adds three calls to the same global. Core stays pure - the engine only wires DOM to it.

CallDoes
register(name, def)Define an element type. def.create(node, opts, h) returns state; def.update(state, t) mutates the DOM each frame; optional def.destroy(state, node) releases resources (timers, listeners).
init(target?)Wire up [data-wv] - no arg for all, or a selector / element / NodeList.
destroy(target?)Stop and clean up. Removes only what create added (author content stays), runs your destroy disposer, and restores aria-hidden - so destroy then init re-renders cleanly instead of duplicating.

Helpers passed to create: makeSampler(extra?) (a sampler with element-friendly defaults - shift on, gentle, low frequency), norm(v) (maps ≈[−1,1] to [0,1]), num(v, default), el(tag, class?).

Conventions: marker data-wv, ready class wv--ready, shared loop at 30 fps, all data-* become opts, data-speed scales time. CSS class and element names use a wv- prefix - never dots (p5.waves.loader is not a valid selector).

Accessibility & seeds: decorative elements get aria-hidden="true" automatically; opt out with data-decorative="false" (or set your own aria-hidden) for a meaningful element. data-seed may be any string - non-numeric seeds are hashed to a stable start offset.

Wave shift

Set shift: true to let the library pick a wave, hold it for shiftInterval seconds, then blend into the next over shiftDuration seconds. It repeats while you keep sampling.

const s = VanillaWaves.createSampler({
  shift: true,
  shiftInterval: 2,    // hold each wave 2s
  shiftDuration: 1.5,  // morph over 1.5s
  group: 'gentle',
  amplitude: 80
});

s.waveName;    // "mountain peaks"
s.targetName;  // "batman" (what's coming next)
s.shifting;    // true while morphing
s.mix;         // 0 → 1 during the morph

Note. Shift is not deterministic across page loads: a per-session random offset is mixed in, so the same seed yields a different sequence each reload (identical within one session). This follows the p5.waves behaviour.

Closing shapes

When you sample a wave around a ring, the last point may differ from the first. Sweep over an integer multiple of the wave's period, the x-distance over which wave(x) repeats. Measured periods are listed on the Waves page.

Waves can have different periods, which makes a ring harder to close during a shift. The 'closing' pool contains 19 waves with a shared base period (62.8319 = 2π/0.1). Use sampler.period × lobes for the sweep. These periods are measured approximations; see the experimental note below.

// Read the sweep straight off the sampler.
const ring = VanillaWaves.createSampler({
  shift: true, group: 'closing', amplitude: 30
});
const sweep = ring.period * 8;   // 8 lobes, holds across every shift

sampler.period returns the current wave's measured period (the shared base period for a closing pool, null for non-periodic waves). Full canvasless SVG-ring recipe on the Waves page.

Experimental. Period values may drift by ~0.001 in minor versions. Use these approximate periods for visual work. They are not suitable for plotters or other machines that need exact closure.

Morph between two waves

Pass two names, control the blend with mix from 0 to 1.

const y = VanillaWaves.wave(x, {
  wave: ['classic sine', 'mountain peaks'],
  mix: 0.5   // 0 = first wave, 1 = second
});

Sweep mix over time for a smooth crossfade between two specific shapes - useful when shift is too wild for the look you want.

Wild mode

One dial, unpredictability (0…1), adds noise modulation on top of any wave. At 0 you get the clean wave; at 1, chaos that still respects the wave's envelope.

const y = VanillaWaves.wave(x, {
  wave: 'mountain peaks',
  mode: 'wild',
  unpredictability: 0.7
});

All 35 waves

Each name is also accessible by its index. See the visual gallery →

00classic sine
01sine
02sharp peaks
03square
04pulse
05stepped sine
06mountain peaks
07valleys
08zig-zag sine
09batman
10offset sine
11steps down
12steps
13squared sine
14bumpy sine
15wobble sine
16up down noise
17meta sine
18triangle
19ramp
20saw down
21saw up
22shake out
23grow random
24noise
25fuzzy pulse
26up down pulse
27bald patch
28fuzzy peak sine
29ramp up sine
30triangle sine
31round linked sine
32half sine
33smooth solid sine
34spike sine

Discover them at runtime too: VanillaWaves.list(), .count, .data.

Parity & drift

The math comes from p5.waves v3.6.0. The comparison recorded on 2026-07-16 passed 10,422 parity checks. The current check-parity.mjs runs 8,620 comparisons between the core and generated bundles. See the comparison record for the baseline and drift-watch procedure.

Time is just a number: you pass t in seconds - the library never calls a clock itself. So you can scrub time, run faster than real-time, run backwards, or freeze a moment.

Starter examples

Animated bars

<div id="bars" style="display:flex;align-items:flex-end;gap:3px;height:100px"></div>
<script>
  const box = document.getElementById('bars');
  const bars = Array.from({length: 24}, () => {
    const b = document.createElement('span');
    b.style.cssText = 'width:8px;height:100%;background:#14140f;transform-origin:bottom';
    return box.appendChild(b);
  });
  const s = VanillaWaves.createSampler({ shift: true, amplitude: 1 });
  (function tick(now){
    const t = now / 1000;
    bars.forEach((b, i) => {
      const v = 0.1 + (s.sample(i*0.5, t) + 0.5) * 0.9;
      b.style.transform = 'scaleY(' + v.toFixed(3) + ')';
    });
    requestAnimationFrame(tick);
  })(0);
</script>

Wave as opacity (breathing element)

const s = VanillaWaves.createSampler({ wave: 'classic sine', range: [0.15, 1] });
(function tick(now){
  el.style.opacity = s.sample(0, now / 1000);
  requestAnimationFrame(tick);
})(0);

Wave as hue (colour drift)

const s = VanillaWaves.createSampler({ wave: 'bumpy sine', range: [0, 360] });
(function tick(now){
  el.style.backgroundColor = 'hsl(' + s.sample(0, now / 1000) + ' 70% 55%)';
  requestAnimationFrame(tick);
})(0);

Shift-driven text loader

const ramp = ' ·:-=+*#%@';
const s = VanillaWaves.createSampler({ shift: true, range: [-1, 1] });
(function tick(now){
  const v = s.sample(0, now / 1000);
  const i = Math.max(0, Math.min(ramp.length-1, Math.floor((v+1)/2 * ramp.length)));
  el.textContent = ramp[i];
  requestAnimationFrame(tick);
})(0);

Morph between two waves

(function tick(now){
  const mix = (Math.sin(now * 0.0005) + 1) / 2;
  const v = VanillaWaves.wave(0, {
    wave: ['classic sine', 'triangle'], mix, range: [0, 100]
  });
  bar.style.height = v + '%';
  requestAnimationFrame(tick);
})(0);