p5.js addon library · v3.6.0

p5.waves Guide

You give it a number. It gives you a number back.
That's the whole idea. The rest is just picking which wave shape you want.

Install

Optimized for p5.js 2.x (tested with 2.2.2). Also works with p5.js 1.x.

Two script tags. p5.js first, then p5.waves. Done.

<script src="https://cdn.jsdelivr.net/npm/p5@2.2.2/lib/p5.js"></script>
<script src="https://cdn.jsdelivr.net/gh/seb-prjcts-be/p5.waves@latest/p5.waves.min.js"></script>
<script src="sketch.js"></script>

No npm. No build step. Just script tags.

Your First Sketch

Copy this into a new p5 sketch. You'll see a moving wave line.

function setup() {
  createCanvas(600, 400);
}

function draw() {
  background(245);
  noFill();
  stroke(0);
  beginShape();
  for (let x = 0; x <= width; x += 3) {
    let y = Waves.wave(x, {
      wave: 'mountain peaks',
      t:    millis() / 1000,
      amplitude: 80
    });
    vertex(x, height / 2 + y);
  }
  endShape();
}

That's the whole API in action. Waves.wave() takes a position, returns a number. Use that number for y-position, size, color, rotation, opacity - anything that takes a number.

Now change 'mountain peaks' to 'batman'. Or 'wobble sine'. Or 'fuzzy pulse'. Each one has its own personality. There are 35 to explore.

Three Ways to Call It

From lazy to precise - all three return the same kind of number.

// Lazy - uses the default wave (seed 0)
Waves.wave(x);

// Quick - pick one by name
Waves.wave(x, 'triangle');

// Full control - name, speed, size, everything
Waves.wave(x, {
  wave:      'triangle',
  t:         millis() / 1000,
  amplitude: 50
});

Start lazy. Add options when you need them.

What Can You Do With a Number?

Anything. Seriously. Here are some ideas:

// Move a circle up and down
circle(width / 2, height / 2 + Waves.wave(t, 'sine'), 20);  // t += 0.01 in draw()

// Control opacity
let dotAlpha = Waves.wave(x, { wave: 'pulse', range: [30, 255] });
fill(0, dotAlpha);

// Pick a color
let wHue = Waves.wave(x + y, { wave: 'meta sine', range: [0, 360] });
fill(wHue, 80, 90);

// Size each dot differently
let dotSize = Waves.wave(i, { wave: 'sharp peaks', range: [2, 20] });
circle(x, y, dotSize);

If it accepts a number, a wave can drive it.

The Options

You don't need all of these. Start with the top three. Add more when you're curious.

Start here

OptionWhat it doesDefault
waveWhich shape. Pick a name like 'sine', a number like 9, or blend two: ['sine', 'triangle']. Omit it and you get the seed-based pick.seed 0
tTime. Makes it move. Pass millis() / 1000. No time = frozen wave.0
amplitudeHow big. Output swings from -amplitude to +amplitude.100

Level up

OptionWhat it doesDefault
rangeMap output to any range you want. [0, 255] = perfect for color values. Replaces amplitude.null
frequencyMultiplies x. On sines that squeezes the cycles tighter (high) or stretches them out (low) — but not on every wave, see below.1
seedGive each object its own wave. Same seed always picks the same wave.0
shifttrue = auto-switch to a new random wave every few seconds. Smooth transitions. Different every page load.false
groupWhich pool shift / seed can pick from. 'gentle' = sines & curves (26). 'harsh' = tan/noise/random/unbounded/erratic (9). 'closing' = 19 waves that share one period (experimental). 'all' = everything. Or pass your own list: ['sine', 'triangle'].'all'

Frequency is a zoom, not a speed dial. It multiplies x and nothing else, so what you get depends on where x sits in the formula. 'classic sine' is sin(x*.1), so it becomes sin(2x*.1): tighter cycles, same height. But 'half sine' is sin(x*.05)*(x*.1%.5), which carries x twice — once in the wave, once in the envelope — so raising frequency tightens the rhythm and grows a taller envelope at the same time. And 'shake out' is a mirrored log chirp whose rhythm lives at the start of each period, so frequency slides that burst sideways (and tightens the period) rather than speeding the whole wave up evenly. One dial, several outcomes.

Go deeper

OptionWhat it doesDefault
phaseNudge the wave sideways.0
mode'stable' = clean. 'wild' = wobbly and unpredictable.'stable'
unpredictabilityChaos dial for wild mode. 0 = calm. 1 = full chaos.0
mixWhen blending two waves: 0 = all first wave, 1 = all second wave.0.5
shiftIntervalHow long to hold each wave before switching (in units of t).3
shiftDurationHow long the smooth transition takes (in units of t).1

All 35 Waves

Use the name or the number. Yes, there's one called batman. Try them all in the Wave Lab.

0 classic sine
1 sine
2 sharp peaks
3 square
4 pulse
5 stepped sine
6 mountain peaks
7 valleys
8 zig-zag sine
9 batman
10 offset sine
11 steps down
12 steps
13 squared sine
14 bumpy sine
15 wobble sine
16 up down noise
17 meta sine
18 triangle
19 ramp
20 saw down
21 saw up
22 shake out
23 grow random
24 noise
25 fuzzy pulse
26 up down pulse
27 bald patch
28 fuzzy peak sine
29 ramp up sine
30 triangle sine
31 round linked sine
32 half sine
33 smooth solid sine
34 spike sine

Closing Curved Shapes

When you sample a wave around a circle, the last vertex rarely lines up with the first one - endShape(CLOSE) then draws an ugly seam back to the start. The fix is to sweep over an integer multiple of the wave's period (the x-distance over which wave(x) repeats).

Every periodic wave in the library has a measured period, listed on the Waves page. For 'classic sine' it's 62.83. Multiply by however many lobes you want around the ring.

Why 62.83 and not 360? x is a generic coordinate here, like in noise(x) - not an angle. Ten of the 35 waves are not sine-based at all (square, triangle, ramp, noise...), so radians would be meaningless for them.

function setup() {
  createCanvas(600, 600);
  noFill();
  stroke(0);
}

function draw() {
  background(245);
  translate(width / 2, height / 2);

  const period = 62.83;         // classic sine
  const lobes  = 8;             // bumps around the ring
  const steps  = 240;           // resolution
  const sweep  = lobes * period;

  beginShape();
  for (let i = 0; i < steps; i++) {
    const a = (i / steps) * TWO_PI;
    const r = 180 + Waves.wave(i / steps * sweep, {
      wave: 'classic sine',
      t:    millis() / 1000,
      amplitude: 30
    });
    vertex(r * cos(a), r * sin(a));
  }
  endShape(CLOSE);
}

Change lobes to 3, 5, 13 - any positive integer closes cleanly. Change steps for finer or coarser resolution; it never affects whether the shape closes.

Some waves have no clean period - 'noise', 'meta sine', and the rest of the no-close group on the Waves page. For those, use two rings (inner + outer) and connect them with radial lines, or fade the stroke to zero at the endpoints instead of closing.

Wave Shift - The Fun Part

This is the feature that makes people go "whoa". One flag, and your wave starts switching to random formulas on its own. Smooth morphs. Different sequence every time you reload.

let sampler = Waves.createSampler({
  shift:         true,        // that's it. that's the flag.
  amplitude:     60,
  shiftInterval: 4,          // hold each wave 4 seconds
  shiftDuration: 1.5         // morph over 1.5 seconds
});

function draw() {
  background(245);
  let t = millis() / 1000;

  beginShape();
  for (let y = 0; y <= height; y += 3) {
    vertex(width / 2 + sampler.sample(y, t), y);
  }
  endShape();

  // Want to show what's playing?
  text(sampler.waveName, 10, 20);     // "mountain peaks"
  text(sampler.shifting, 10, 40);     // true while morphing
  text(sampler.targetName, 10, 60);   // "batman" (what's coming next)
  text(sampler.mix, 10, 80);          // 0.0 -> 1.0 during morph
}

Shift has no internal clock — it reads the t value you pass in. With millis()/1000 that means real seconds. Pass something else (camera position, scroll offset) and shift follows that instead.

Reload the page. Different waves. Every time. Your sketch is never the same twice.

Shift also works directly on Waves.wave() - just add shift: true to the options. But the sampler version is better: it caches everything and gives you the .waveName / .shifting / .mix getters for free.

Pick a pool with group

Shift can land on any of the 35 formulas - including tan spikes and noise. If you want to stay in calm territory (or the opposite), narrow the pool.

Waves.createSampler({ shift: true, group: 'gentle' });  // sines & curves only (26 waves)
Waves.createSampler({ shift: true, group: 'harsh' });   // tan/noise/random/unbounded/erratic only (9 waves)
Waves.createSampler({ shift: true, group: 'closing' }); // 19 waves that all close on the same sweep
Waves.createSampler({ shift: true, group: ['sine', 'triangle', 'batman'] });  // your own list

mode vs group - don't confuse them. mode: 'wild' warps one wave (frequency + phase + amplitude noise). group: 'harsh' picks a different kind of wave - one with spikes baked in. They're orthogonal: { mode: 'wild', group: 'gentle' } = breathing sines, no spikes.

Stay closed while shifting - group: 'closing'

The two best features fight each other by default. A ring sampled with shift tears its seam open the moment it lands on a new wave, because every formula has its own period and your fixed sweep stops lining up. The 'closing' pool ends that fight. All 19 waves in it share one base period (62.8319, that's 2π/0.1), so a sweep of sampler.period × lobes closes seamlessly through every transition - the shape keeps morphing into new waves and never shows a seam.

let ring = Waves.createSampler({
  shift:     true,
  group:     'closing',     // 19 waves that all close on the same sweep
  amplitude: 30
});

function draw() {
  background(245);
  translate(width / 2, height / 2);

  const sweep = ring.period * 8;   // 8 lobes, holds across every shift
  beginShape();
  for (let i = 0; i < 240; i++) {
    const a = (i / 240) * TWO_PI;
    const r = 180 + ring.sample(i / 240 * sweep, millis() / 1000);
    vertex(r * cos(a), r * sin(a));
  }
  endShape(CLOSE);
}

sampler.period and sampler.targetPeriod report the current and next wave's measured period - both return the stable base for a closing pool, null for non-periodic waves. So you read the sweep length straight off the sampler instead of hard-coding a number per wave.

Experimental in 3.3.0. Period values may drift by ~0.001 in minor versions. Perfect for visuals; not for plotters, CNC, or anything that gets angry about sub-unit drift.

Morph - Blend Two Waves

Pick two waves. Slide between them. Connect mix to your mouse and you get a live crossfader.

let y = Waves.wave(x, {
  wave: ['sine', 'batman'],    // sine on the left, batman on the right
  mix:  mouseX / width,        // drag to blend
  t:    millis() / 1000,
  amplitude: 40
});

mix: 0 = pure first wave. mix: 1 = pure second wave. Anything in between = a blend of both. Animate it for smooth shape transformations.

createSampler() - For Loops and Particles

If you're calling the same wave 200 times per frame (particles, grid cells, trail points), set up a sampler once and call .sample() instead. Same result, less repeated work.

let s = Waves.createSampler({
  wave:  'triangle',
  range: [-80, 80]
});

// Then in draw, call it as many times as you want:
s.sample(y);          // position only
s.sample(y, t);       // position + time
s.sample(y, t, mix);  // position + time + morph blend
s.waveName;           // "triangle"

Takes all the same options as Waves.wave(). Including shift: true.

Two samplers for independent axes

Want X and Y to move independently? Two samplers, two different seeds.

let sx = Waves.createSampler({ seed: 0, range: [-80, 80] });
let sz = Waves.createSampler({ seed: 1, range: [-80, 80] });
// Different seed = different wave = uncorrelated motion

Binary Fields - Two Waves, Threshold the Sum

Two-dimensional patterns from one principle: take two samplers, sum their outputs per cell, and threshold. One wave runs across the rows, another down the columns. Where they line up, the cell turns on; where they don't, it turns off.

The formula: cell = rowSampler(row) + colSampler(col) > threshold. This is why the choice of waves matters: two sines at the same frequency give interference patterns, two pulses give a checkerboard, a sine + a pulse gives striped bands. The library doesn't ship a grid wrapper - the nested loop is short enough to write directly, and that gives you full control (animate via position offset, swap waves with shift mode, layer multiple grids, anything).

const rowS = Waves.createSampler({ wave: 'classic sine', range: [-1, 1] });
const colS = Waves.createSampler({ wave: 'triangle',     range: [-1, 1] });

function draw() {
  let t = millis() / 1000;
  for (let row = 0; row < rows; row++) {
    let rv = rowS.sample(row * 5 + t);    // x-step ~ one wave period
    for (let col = 0; col < cols; col++) {
      let cv = colS.sample(col * 2.5 - t);
      let on = (rv + cv) > 0;             // threshold
      fill(on ? 0 : 245);
      rect(col * cw, row * ch, cw, ch);
    }
  }
}

Variations. Make the samplers shift: true and the waves themselves cycle - the field evolves character over time. Tighten the threshold to 0.12 for sparser marks, or use -0.12 for denser. Replace fill(on ? 0 : 245) with a colour mapping for an analog field. The about page ships the original 16×16 origin grid (the sketch that started p5.waves) - the simplest possible version, with auto-advance when the field goes uniform.

Wild Mode - Controlled Chaos

Turn on mode: 'wild' and the wave gets wobbly. Frequency, phase, and amplitude all start drifting. It stays recognizable - just... unhinged.

let y = Waves.wave(x, {
  wave:             'pulse',
  t:                millis() / 1000,
  mode:             'wild',
  unpredictability: 0.45,    // 0 = calm, 1 = full chaos
  amplitude:        80
});

Start at 0.3 and work your way up. At 1.0 things get interesting. Wild mode is about 5x slower though - so don't use it in a 10,000-point loop unless you're ready for that.

Time - You Control It

There's no built-in clock. You pass t on every call. That means you're in charge.

// Tie time to the mouse - scrub through the wave
let t = map(mouseX, 0, width, 0, 10);
let y = Waves.wave(x, { wave: 'sine', t: t, amplitude: 50 });

Want to pause? Stop changing t. Want to rewind? Decrease it. Want slow motion? Use a tiny increment. Want to sync two sketches? Give them the same t.

Seed vs Index - Don't Mix Them Up

These look similar but do different things:

Waves.wave(x, 3)             // seed 3 -> hashed to SOME wave (not necessarily #3)
Waves.wave(x, { wave: 3 })   // index 3 -> exactly wave #3 (square)

Index = "I want that exact wave." Seed = "Give each of my 50 particles its own consistent wave - I don't care which."

Shorthand Names

Waves.* always works. But in a regular p5 sketch, shorter names are available too.

Always worksp5 global modep5 instance mode
Waves.wave(y, opts)waves(y, opts)p.waves(y, opts)
Waves.createSampler(opts)createWaveSampler(opts)p.createWaveSampler(opts)

Extras: Waves.list() returns all 35 formulas. Waves.count = 35. Waves.data = the raw formula array. Waves.benchmark(config, n) runs n wave calls and returns { iterations, ms, callsPerMs }.

Copy-Paste Starters

Grab one. Drop it in your sketch. Go.

Just a wave

let y = Waves.wave(x, 'mountain peaks');

Moving wave

let y = Waves.wave(x, {
  wave: 'mountain peaks',
  t:    millis() / 1000,
  amplitude: 80
});

Wave as color

let dotAlpha = Waves.wave(x, {
  wave:  'pulse',
  t:     millis() / 1000,
  range: [30, 255]
});
fill(0, dotAlpha);

Auto-shifting

let s = Waves.createSampler({
  shift:     true,
  amplitude: 60
});
// in draw:
s.sample(x, millis() / 1000);

Binary field

const rowS = Waves.createSampler({ range: [-1, 1] });
const colS = Waves.createSampler({ seed: 1, range: [-1, 1] });
// in draw, per cell:
let v = rowS.sample(row * 5 + t) + colS.sample(col * 2.5 - t);
let on = v > 0;

50 particles, each with its own wave

for (let i = 0; i < 50; i++) {
  let y = Waves.wave(t, {  // t += 0.01 in draw()
    seed:      i,               // each particle gets its own wave
    t:         millis() / 1000,
    amplitude: 30
  });
  circle(i * 12, height / 2 + y, 6);
}