<canvas id="canvas"></canvas>
body {
margin: 0;
min-height: 100vh;
height: 1px;
}
#canvas {
display: block;
width: 100%;
height: 100%;
}
const duration = 20_000;
const bubblesTotal = 100;
const tubeWidth = 30;
const lightRadius = 100;
const path = 'M35,210V81A15,15,0,0,1,50,66H94a15,15,0,0,0,15-15V40a15,15,0,0,1,15-15H254a15,15,0,0,1,15,15V82a15,15,0,0,1-15,15H166a15,15,0,0,0-15,15v40a15,15,0,0,0,15,15H290a15,15,0,0,1,15,15v28a15,15,0,0,1-15,15H50A15,15,0,0,1,35,210Z';
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svgPath.setAttribute('d', path);
const pathLength = svgPath.getTotalLength();
const pointAtLength = cachePathPoints(svgPath);
const canvasPath = new Path2D(path);
const canvas = document.querySelector('#canvas');
let width = canvas.clientWidth;
let height = canvas.clientHeight;
const ctx = canvas.getContext('2d');
const simplex = new SimplexNoise();
function onResize() {
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
}
window.addEventListener('resize', onResize);
onResize();
let begin = 0;
function loop(now) {
begin = begin || now;
const t = (now - begin) % duration / duration;
ctx.clearRect(0, 0, width, height);
ctx.lineJoin = 'round';
ctx.lineWidth = tubeWidth;
ctx.strokeStyle = '#b3d5f6';
ctx.stroke(canvasPath);
// light
const light = pointAtLength(pathLength * t);
ctx.globalCompositeOperation = 'soft-light';
const gradient = ctx.createRadialGradient(light.x, light.y, 0, light.x, light.y, lightRadius);
gradient.addColorStop(0, '#fff');
gradient.addColorStop(1, '#7f7f7f');
ctx.beginPath();
ctx.arc(light.x, light.y, lightRadius, 0, 2 * Math.PI);
ctx.fillStyle = gradient;
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
// mask
ctx.globalCompositeOperation = 'destination-in';
ctx.lineJoin = 'round';
ctx.lineWidth = tubeWidth - 1;
ctx.strokeStyle = '#fff';
ctx.stroke(canvasPath);
ctx.globalCompositeOperation = 'source-over';
// bg
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = '#ffe7a3';
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
// bubbles
for (let i = 0; i < bubblesTotal; i++) {
const n1 = simplex.noise2D(now * 0.00005, i);
const n2 = simplex.noise2D(now * 0.0005, i);
const len = ((pathLength * t - i * 5 * n1) % pathLength + pathLength) % pathLength;
const point = pointAtLength(len);
const r = tubeWidth * ((n1 + 1) * 0.25 * 0.2 + 0.15);
const x = point.x + Math.cos(point.angle + Math.PI * 0.5) * (tubeWidth * 0.5 - r) * n2;
const y = point.y + Math.sin(point.angle + Math.PI * 0.5) * (tubeWidth * 0.5 - r) * n2;
drawBubble(ctx, x, y, r);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
function cachePathPoints(path) {
const points = [];
for (let i = 0, l = Math.ceil(pathLength); i < l; i++) {
points[i] = path.getPointAtLength(i);
}
return function pointAtLength(len) {
const idx = Math.max(0, Math.min(len, points.length - 1));
const fract = Math.abs(Math.floor(len) - len);
const p1 = points[Math.floor(idx)];
const p2 = points[Math.ceil(idx)];
const p = {
x: lerp(p1.x, p2.x, fract),
y: lerp(p1.y, p2.y, fract),
};
const hasPrev = Math.floor(idx - 1) > -1;
let pp1, pp2;
if (hasPrev) {
pp1 = points[Math.floor(idx - 1)];
pp2 = points[Math.ceil(idx - 1)];
}
else {
pp1 = points[Math.floor(idx + 1)];
pp2 = points[Math.ceil(idx + 1)];
}
const pprev = {
x: lerp(pp1.x, pp2.x, fract),
y: lerp(pp1.y, pp2.y, fract),
};
const angle = hasPrev
? Math.atan2(p.y - pprev.y, p.x - pprev.x)
: Math.atan2(pprev.y - p.y, pprev.x - p.x);
return {
x: p.x,
y: p.y,
angle,
}
}
}
function drawBubble(ctx, x, y, r) {
ctx.save();
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
const offs = r * Math.SQRT1_2 * 0.6;
const gradient = ctx.createRadialGradient(
x - offs, y - offs, 0.1,
x, y, r
);
gradient.addColorStop(0, '#e4eff6');
gradient.addColorStop(1, '#2f9eed');
ctx.fillStyle = gradient;
ctx.fill();
ctx.restore();
}
function lerp(v1, v2, t) {
return (1 - t) * v1 + t * v2;
}
This Pen doesn't use any external CSS resources.