<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;
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/2.4.0/simplex-noise.min.js