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
| Option | Default | Description |
|---|---|---|
| wave | null | A name, an index (0…34), or [a, b] to morph. null → picked by seed. |
| amplitude | 100 | Output is scaled to [−amp, +amp]. |
| frequency | 1 | Multiplier 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. |
| t | 0 | Time, in seconds. Anything that moves reads this. |
| seed | 0 | Picks the wave when wave is null; seeds the PRNG for harsh waves. |
Phase, range, mix and group
| Option | Default | Description |
|---|---|---|
| phase | 0 | Added to x before evaluation. Shifts the wave horizontally. |
| range | null | [lo, hi] - map output to this range instead of amplitude scaling. Use for opacity 0–1, hue 0–360, etc. |
| mix | 0.5 | Morph 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
| Option | Default | Description |
|---|---|---|
| shift | false | Auto-cycle through the group, smoothly morphing between waves. |
| shiftInterval | 3 | Seconds each wave is held. |
| shiftDuration | 1 | Seconds to morph between consecutive waves. |
| mode | 'stable' | 'wild' enables noise modulation on top of the wave. |
| unpredictability | 0 | 0…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.
| Call | Does | |
|---|---|---|
| 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 →
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);