<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
  <svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
    <g stroke-width="6" fill="#fff" stroke="#333">
      <circle id="js-circle1" cx="400" cy="600" r="96" />
      <circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
      <path id="js-connector" d="" />
    </g>
  </svg>
</main>
// Dom Nodes

const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const debugPts = document.querySelectorAll('#js-debug circle');
const debugLines = document.querySelectorAll('#js-debug line');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
  CIRCLE1: 96,
  CIRCLE2: 64,
};

const animation$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
  .map(frame => 1 - 0.5 * Math.abs(Math.sin(frame / 200)));

const circle1$ = Rx.Observable.of([600, 600])
  .do(loc => { moveTo(loc, circle1); });

const circle2$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
  .map(frame => 200 * Math.sin(frame / 500))
  .map(x => [600 + x, 600])
  .do(loc => { moveTo(loc, circle2); });

Rx.Observable
  .combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc) =>
    metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc),
  )
  .subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
    connector.setAttribute('d', path);
  });


/**
 * Based on Metaball script by SATO Hiroyuki
 * http://shspage.com/aijs/en/#metaball
 */
function metaball(
  radius1,
  radius2,
  center1,
  center2,
  v = 0.5,
  handleLenRate = 2.4,
) {
  const HALF_PI = Math.PI / 2;
  const d = dist(center1, center2);
  const maxDist = radius1 + radius2 * 2.5;
  let u1, u2;
    
  if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
    return [''];
  }
    
  if (d < radius1 + radius2) {
    u1 = Math.acos(
      (radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
    );
    u2 = Math.acos(
      (radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
    );
  } else {
    u1 = 0;
    u2 = 0;
  }

  // All the angles
  const angleBetweenCenters = angle(center2, center1);
  const spread = Math.acos((radius1 - radius2) / d);
    
  const angle1 = angleBetweenCenters + spread * v;
  const angle2 = angleBetweenCenters - spread * v;
  const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - spread) * v);
  const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - spread) * v);
  
  // Points
  const p1 = getVector(center1, angle1, radius1);
  const p2 = getVector(center1, angle2, radius1);
  const p3 = getVector(center2, angle3, radius2);
  const p4 = getVector(center2, angle4, radius2);

  // Define handle length by the
  // distance between both ends of the curve
  const totalRadius = radius1 + radius2;
  const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);

  // Take into account when circles are overlapping
  const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));

  const r1 = radius1 * d2;
  const r2 = radius2 * d2;

  const h1 = getVector(p1, angle1 - HALF_PI, r1);
  const h2 = getVector(p2, angle2 + HALF_PI, r1);
  const h3 = getVector(p3, angle3 + HALF_PI, r2);
  const h4 = getVector(p4, angle4 - HALF_PI, r2);

  const path = metaballToPath(
    p1, p2, p3, p4,
    h1, h2, h3, h4,
    d > radius1,
    radius2,
  );
    
  return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}

function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
  // prettier-ignore
  return [
    'M', p1,
    'C', h1, h3, p3,
    'A', r, r, 0, escaped ? 1 : 0, 0, p4,
    'C', h4, h2, p2,
  ].join(' ');
}

/**
 * Utils
 */
function moveTo([x, y] = [0, 0], element) {
  element.setAttribute('cx', x);
  element.setAttribute('cy', y);
}

function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
  element.setAttribute('x1', x1);
  element.setAttribute('y1', y1);
  element.setAttribute('x2', x2);
  element.setAttribute('y2', y2);
}

function dist([x1, y1], [x2, y2]) {
  return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}

function angle([x1, y1], [x2, y2]) {
  return Math.atan2(y1 - y2, x1 - x2);
}

function getVector([cx, cy], a, r) {
  return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
View Compiled

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tachyons/4.6.2/tachyons.min.css

External JavaScript

  1. https://unpkg.com/@reactivex/rxjs@5.4.3/dist/global/Rx.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js