Wave Lines
Layered sine lines undulate across a section, each a little out of step with the next, so the bundle twists and breathes like a ribbon in the wind. The colours blend from the first line to the last.
Go with the flow
How to use it
- Paste the snippet just before
</head>. - Add
data-wave-linesto a section, hero or card.
Options
Add any of these attributes to the same element.
data-colors | Two or more colours, blended across the lines. Default violet, pink and sky blue. |
data-lines | How many lines. Default 12. |
data-amplitude | Wave height, as a share of the section’s height. Default 0.16. |
data-position | Where the waves sit, from 0 (top) to 1 (bottom). Default 0.62. |
data-speed | Speed multiplier. Default 1. |
data-width | Line width in px. Default 1.5. |
It’s drawn on a canvas behind your content and paused when off screen. With reduced motion the lines stay still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Wave Lines: layered sine lines undulate across a section, each a little
// out of step with the next, so the bundle twists and breathes like a
// ribbon. Colours blend from the first line to the last. Drawn on a canvas
// behind your content, and paused when off screen.
// Usage: <section data-wave-lines>…</section>
// Optional: data-colors (two or more, blended across the lines, default
// #a855f7,#ec4899,#38bdf8), data-lines (default 12), data-amplitude
// (wave height as a share of the section's height, default 0.16),
// data-position (where the waves sit, 0 top to 1 bottom, default 0.62),
// data-speed (default 1), data-width (line width in px, default 1.5).
var DEFAULTS = { colors: ['#a855f7', '#ec4899', '#38bdf8'], lines: 12, amplitude: 0.16, position: 0.62, speed: 1, width: 1.5 };
var css =
'[data-wave-lines]{position:relative;isolation:isolate;overflow:hidden}' +
'.wl-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;
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);
}
// The colour a share t (0–1) of the way through the list.
function blend(cols, t) {
var f = t * (cols.length - 1), i = Math.min(cols.length - 2, Math.floor(f)), k = f - i;
if (cols.length < 2) return 'rgb(' + cols[0].join(',') + ')';
return 'rgb(' + [0, 1, 2].map(function (c) { return Math.round(cols[i][c] + (cols[i + 1][c] - cols[i][c]) * k); }).join(',') + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'wl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var cols = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return rgb(c.trim()); });
var lines = Math.max(1, +d.lines || DEFAULTS.lines);
var amp = d.amplitude !== undefined ? +d.amplitude : DEFAULTS.amplitude;
var pos = d.position !== undefined ? +d.position : DEFAULTS.position;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var width = +d.width || DEFAULTS.width;
var strokes = [];
for (var i = 0; i < lines; i++) strokes.push(blend(cols, lines > 1 ? i / (lines - 1) : 0));
var w = 0, h = 0, dpr = 1;
var clock = { t: 0 };
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
draw();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineWidth = width;
ctx.lineJoin = 'round';
var t = clock.t, mid = h * pos, a = h * amp;
var step = Math.max(6, w / 90);
for (var i = 0; i < lines; i++) {
var k = lines > 1 ? i / (lines - 1) : 0.5;
// Each line shifts phase and height a little: the bundle twists.
var phase = k * 2.4, lift = (k - 0.5) * a * 0.9;
ctx.strokeStyle = strokes[i];
ctx.globalAlpha = 0.35 + 0.65 * Math.sin(Math.PI * (0.15 + k * 0.7));
ctx.beginPath();
for (var x = -step; x <= w + step; x += step) {
var u = x / Math.max(w, 1);
var y = mid + lift * Math.sin(t * 0.7 + u * 3) +
a * Math.sin(u * 5.2 + t + phase) * 0.62 +
a * Math.sin(u * 2.3 - t * 0.6 + phase * 0.6) * 0.38;
if (x === -step) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// One slow, endless clock drives every line. A lap is a whole number of
// turns for each wave in it, so it loops without a jump.
var lap = Math.PI * 20;
var anim = anime.animate(clock, {
t: [0, lap],
duration: (lap / 0.9 / Math.max(0.01, speed)) * 1000,
ease: 'linear',
loop: true,
autoplay: false,
onUpdate: draw
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) anim.play(); else anim.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-wave-lines]').forEach(function (el) {
if (el._wl) return;
el._wl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-wave-lines data-colors="#6366f1,#ec4899">
<h1>Go with the flow</h1>
</section>