Ocean Swell
Layer upon layer of waves roll across a section, from small, pale ones out on the horizon to big, deep ones up front, each with a line of white foam breaking along its sharp crests.
Make waves
How to use it
- Paste the snippet just before
</head>. - Add
data-ocean-swellto a section with a sky-coloured background.
Options
Add any of these attributes to the same element.
data-colors | Two colours: the far water, then the near water. Default #5ccfe6,#075985. |
data-layers | How many layers of waves. Default 5. |
data-height | Wave height multiplier. Default 1. |
data-horizon | Where the sea starts, from 0 (top) to 1 (bottom). Default 0.4. |
data-speed | Speed multiplier. Default 1. |
data-foam="false" | No foam on the crests. |
The waves follow a real wave shape (a trochoid), so the crests are sharp and the troughs wide, and bigger waves travel faster. It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the sea holds still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Ocean Swell: layered waves roll across a section, from small, pale ones
// on the horizon to big, deep ones up front, each with a line of white
// foam breaking along its crests. Drawn on a transparent canvas behind
// your content, and paused when off screen.
// Usage: <section data-ocean-swell style="background:linear-gradient(#7dd3fc,#e0f2fe)">…</section>
// Optional: data-colors (two colours: the far water, then the near water,
// default "#5ccfe6,#075985"), data-layers (number of waves, default 5),
// data-height (wave height multiplier, default 1), data-speed (default 1),
// data-horizon (where the sea starts, 0 = top, 1 = bottom, default 0.4),
// data-foam="false" (no foam).
var DEFAULTS = { colors: '#5ccfe6,#075985', layers: 5, height: 1, speed: 1, horizon: 0.4 };
var css =
'[data-ocean-swell]{position:relative;isolation:isolate;overflow:hidden}' +
'.osw-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c.trim();
var v = probe.fillStyle;
if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
return v.match(/[\d.]+/g).slice(0, 3).map(Number);
}
function mix(a, b, f) { return [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * f); }); }
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function build(el) {
var d = el.dataset;
var cols = (d.colors || DEFAULTS.colors).split(',');
var far = rgb(cols[0]), near = rgb(cols[1] || cols[0]);
var count = Math.max(1, +d.layers || DEFAULTS.layers);
var height = d.height !== undefined ? +d.height : DEFAULTS.height;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var horizon = d.horizon !== undefined ? +d.horizon : DEFAULTS.horizon;
var foamy = d.foam !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'osw-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, t = 0, layers = [];
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Each layer is one wave train: further back means higher up the
// section, smaller, paler and slower.
var seeds = [];
for (var i = 0; i < count; i++) seeds.push({ ph: R(0, 6.28, 2), ph2: R(0, 6.28, 2), dir: i % 2 ? 1 : 0.85 });
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
layers = seeds.map(function (s, i) {
var f = count === 1 ? 1 : i / (count - 1);
var lambda = Math.max(70, Math.min(w * 0.6, h * (0.35 + 0.9 * f)));
var amp = h * (0.012 + 0.04 * f) * height;
// Keep the crests sharp but never folding over.
amp = Math.min(amp, lambda / (Math.PI * 2) / 0.95);
return {
f: f,
base: h * (horizon + (1 - horizon) * (0.06 + 0.78 * Math.pow(f, 0.9))),
amp: amp,
k: (Math.PI * 2) / lambda,
// Bigger waves travel faster, as they do at sea.
omega: Math.sqrt((Math.PI * 2) / lambda) * 5 * s.dir,
ph: s.ph, ph2: s.ph2,
col: mix(far, near, f)
};
});
draw();
}
// A trochoid: points on the surface circle as the wave passes, which
// bunches them up at the crests (sharp) and spreads them in the troughs.
function surface(L) {
var pts = [], step = 5, q = 0.85;
for (var p = -L.amp * 2 - step; p <= w + L.amp * 2 + step; p += step) {
var th = L.k * p - L.omega * t + L.ph;
var th2 = L.k * 1.7 * p + L.omega * 1.3 * t + L.ph2;
var x = p - q * L.amp * Math.sin(th);
var y = L.base - L.amp * Math.cos(th) - L.amp * 0.22 * Math.sin(th2);
pts.push([x, y, Math.cos(th)]);
}
return pts;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineJoin = 'round';
for (var i = 0; i < layers.length; i++) {
var L = layers[i], pts = surface(L);
var top = L.base - L.amp * 1.25;
// Body: lit near the crest, deepening below.
var g = ctx.createLinearGradient(0, top, 0, Math.min(h, L.base + L.amp * 2 + h * 0.18));
g.addColorStop(0, rgba(mix(L.col, [255, 255, 255], 0.22), 1));
g.addColorStop(0.35, rgba(L.col, 1));
g.addColorStop(1, rgba(mix(L.col, [0, 10, 30], 0.35), 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(pts[0][0], h + 2);
for (var k = 0; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.lineTo(pts[pts.length - 1][0], h + 2);
ctx.closePath();
ctx.fill();
if (!foamy) continue;
// Foam where the water is highest: a bright line along each crest,
// and a fainter lace just behind it.
var lw = 1 + 2.2 * L.f;
for (var j = 1; j < pts.length; j++) {
var c = (pts[j][2] + pts[j - 1][2]) / 2;
var a = Math.max(0, (c - 0.35) / 0.65);
if (a < 0.03) continue;
ctx.strokeStyle = 'rgba(255,255,255,' + (a * (0.55 + 0.35 * L.f)).toFixed(3) + ')';
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(pts[j - 1][0], pts[j - 1][1] + lw * 0.3);
ctx.lineTo(pts[j][0], pts[j][1] + lw * 0.3);
ctx.stroke();
if (L.f > 0.3 && (j * 7 + i * 3) % 4 === 0) {
ctx.fillStyle = 'rgba(255,255,255,' + (a * 0.35).toFixed(3) + ')';
var off = lw * 2 + ((j * 13) % 5) * lw * 0.9;
ctx.beginPath();
ctx.arc(pts[j][0], pts[j][1] + off, lw * 0.55, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) return;
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
t += (dt / 1000) * speed;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-ocean-swell]').forEach(function (el) {
if (el._osw) return;
el._osw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-ocean-swell style="background: linear-gradient(#7dd3fc, #e0f2fe); color: #fff">
<h1>Make waves</h1>
</section>