Canvas Wave Background — Free Layered Sine Ribbon Hero Effect
Canvas Wave Background · Animations · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="cwb-wrap">
<canvas class="cwb-canvas" id="cwbCanvas"></canvas>
<div class="cwb-content">
<span class="cwb-tag">canvas 2d · layered sine ribbons</span>
<h1>Ride the current</h1>
<p>Four filled wave layers moving at independent speeds, amplitudes, and colors.</p>
<button class="cwb-btn">Get started</button>
</div>
</section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#031018;color:#fff;min-height:100vh}
.cwb-wrap{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center}
.cwb-canvas{position:absolute;inset:0;width:100%;height:100%}
.cwb-content{position:relative;z-index:1;text-align:center;max-width:460px;padding:26px}
.cwb-tag{display:inline-block;font-size:10.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#67e8f9;background:rgba(103,232,249,.1);border:1px solid rgba(103,232,249,.3);padding:5px 12px;border-radius:99px;margin-bottom:16px}
.cwb-content h1{font-size:clamp(34px,7vw,58px);font-weight:800;letter-spacing:-.03em}
.cwb-content p{font-size:14.5px;color:#a7c5ce;margin-top:12px;line-height:1.7}
.cwb-btn{margin-top:26px;padding:14px 30px;border-radius:12px;border:none;background:linear-gradient(135deg,#22d3ee,#0ea5e9);color:#031018;font:700 14px system-ui;cursor:pointer;box-shadow:0 16px 34px -16px rgba(34,211,238,.6)}var canvas = document.getElementById('cwbCanvas');
var ctx = canvas.getContext('2d');
var wrap = document.querySelector('.cwb-wrap');
var W, H, DPR;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = wrap.clientWidth;
H = wrap.clientHeight;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener('resize', resize);
// Each layer is an independent sine ribbon: its own baseline height, amplitude,
// wavelength, horizontal speed, and fill. Layering several with staggered
// baselines and decreasing opacity toward the back is what reads as depth —
// a single wave just looks like a wiggling line.
var LAYERS = [
{ baseline: 0.78, amplitude: 22, wavelength: 340, speed: 0.55, color: 'rgba(34,211,238,0.55)' },
{ baseline: 0.83, amplitude: 30, wavelength: 260, speed: -0.35, color: 'rgba(14,165,233,0.5)' },
{ baseline: 0.9, amplitude: 26, wavelength: 420, speed: 0.22, color: 'rgba(56,189,248,0.42)' },
{ baseline: 0.97, amplitude: 18, wavelength: 200, speed: -0.7, color: 'rgba(125,211,252,0.38)' }
];
function drawLayer(layer, t) {
var baseY = H * layer.baseline;
var k = (Math.PI * 2) / layer.wavelength;
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, baseY);
for (var x = 0; x <= W; x += 6) {
var y = baseY + Math.sin(x * k + t * layer.speed) * layer.amplitude
+ Math.sin(x * k * 1.7 - t * layer.speed * 1.3) * (layer.amplitude * 0.3);
ctx.lineTo(x, y);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = layer.color;
ctx.fill();
}
var t = 0;
function tick() {
t += 0.9;
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < LAYERS.length; i++) drawLayer(LAYERS[i], t);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Canvas Wave Background — Layered Sine Ribbons for a Sense of Depth

A single animated sine wave reads as a wiggling line. This snippet stacks four of them — each with its own baseline height, amplitude, wavelength, speed, and translucent fill — to produce the smooth, layered look of ocean swells rolling behind a hero section, built entirely with filled canvas paths rather than strokes.
Why filled shapes instead of strokes
Every layer is drawn as a closed path: start at the bottom-left corner, trace the sine curve left to right, drop down to the bottom-right corner, and close back to the start. Filling that shape with a translucent color is what gives each wave visual weight and lets it convincingly occlude the layers behind it — a stroked line would only ever look like a wire outline, with nothing suggesting mass or water beneath the surface.
Depth from staggered baselines and speed, not blur
Each of the four LAYERS has a different baseline (how far down the canvas its resting line sits) and a different speed — including negative values, so some layers drift right while others drift left. Combined with decreasing opacity for layers further down, that's the entire depth illusion: nearer waves move faster and sit higher with more contrast, farther waves move slower, sit lower, and fade toward the background color. No blur filter or z-axis is involved.
Two sine terms per layer, not one
drawLayer() doesn't plot a pure sine curve — it sums a primary wave with a smaller secondary one at a different frequency and phase speed: Math.sin(x * k + t * speed) * amplitude plus a lighter Math.sin(x * k * 1.7 - t * speed * 1.3) term. That second term breaks up the otherwise perfectly regular crest spacing, so each ribbon has a subtle irregularity to it rather than looking like a textbook sine plot — the same "combine two waves" idea used in aurora background and wavy background, applied here to filled ribbons instead of strokes or gradients.
High-DPI sizing done once
resize() scales the canvas backing store by devicePixelRatio (capped at 2) and applies a matching ctx.setTransform, so every coordinate in drawLayer stays in plain CSS pixels while still rendering crisply on Retina displays — the same pattern worth reusing in any full-bleed canvas background.
Tuning the current
Add or remove entries in LAYERS to change how many ribbons stack; push baseline values further down and amplitude up for a stormier feel, or flatten amplitudes and slow every speed for a calm, barely-moving backdrop. Pair it with gradient mesh hero for a softer companion background, or ripple background for an interactive water effect alongside it.
Step by step
How to Use
- 1Paste HTML, CSS, and JSFour wave ribbons animate immediately behind the hero content.
- 2Watch the layeringNearer waves move faster and sit brighter; farther ones lag and fade.
- 3Resize the windowThe canvas rescales for devicePixelRatio and viewport size.
- 4Add a layerPush a new object into LAYERS with its own baseline/speed/color.
- 5Change the moodRaise amplitude and speed for choppier water, lower for calm.
- 6Swap the paletteChange each layer's rgba() fill for a different time of day.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each layer traces a sine curve and then closes the path down to the bottom of the canvas before filling it, which gives the wave visual weight and lets it convincingly occlude the layers behind it. A stroked line would only ever render as a thin wire outline with nothing suggesting mass or water beneath the surface, so filling is what makes the effect read as waves rather than a wiggling graph line.
Depth comes entirely from four values that differ per layer: baseline height (farther layers sit lower), speed (nearer layers move faster, including some drifting in the opposite direction from others), amplitude, and fill opacity (farther layers are more transparent, blending toward the background color). Stacking those differences is enough to read as foreground-to-background depth without any blur filter or actual z-axis.
A single Math.sin term produces perfectly regular, evenly spaced crests that can look mechanical. Adding a second, smaller sine term at a different frequency and phase speed — Math.sin(x * k * 1.7 - t * speed * 1.3) at roughly 30% amplitude — breaks up that regularity, so each ribbon has a subtle, non-repeating irregularity that reads as more natural water motion.
Very high-density phone displays can report a devicePixelRatio of 3 or more, and sizing the canvas backing store that large multiplies memory use and fill-rate cost for very little visible sharpness improvement over 2x. Capping it at 2 keeps the canvas crisp on virtually all displays while avoiding an oversized backing store on the highest-density devices.
Move the canvas ref, resize() call, and requestAnimationFrame loop into a mount effect, store the animation frame id, and cancelAnimationFrame it in the cleanup function so the loop stops when the component unmounts. Re-run resize() from a ResizeObserver on the wrapper element if the background needs to track a container that can change size independent of the window.