<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden code">
  <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" />
    </g>
    
    <text x="505" y="605" font-size="20">spread</text>
    
    <g id="js-debug" stroke-width="8">
      <g stroke="#5E2CA5" stroke-linecap="round">
        <line x1="0" y1="0" x2="0" y2="0" />
        <line x1="0" y1="0" x2="0" y2="0" />
        <line x1="0" y1="0" x2="0" y2="0" />
        <line x1="0" y1="0" x2="0" y2="0" />
        <line x1="0" y1="0" x2="0" y2="0" />
        <line x1="0" y1="0" x2="0" y2="0" />
      </g>
      
      <g fill="#fff" stroke="#FF41B4"> 
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
        <circle cx="" cy="" r="8" />
      </g>
    </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 debugText = document.querySelector('#js-debug-text');
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([487.5, 600])
  .do(loc => { moveTo(loc, circle1); });

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

Rx.Observable
  .combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc, anim) =>
    metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc, anim),
  )
  .subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
    // connector.setAttribute('d', path);
    
    line(p1, p3, debugLines[0]);
    line(p2, p4, debugLines[1]);
    line([487.5, 600], p1, debugLines[2]);
    line([487.5, 600], p2, debugLines[3]);
    line([712.5, 600], p3, debugLines[4]);
    line([712.5, 600], p4, debugLines[5]);
  
    moveTo(p1, debugPts[0]);
    moveTo(p2, debugPts[1]);
    moveTo(p3, debugPts[2]);
    moveTo(p4, debugPts[3]);   
  });


/**
 * 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 + u1 + (spread - u1) * v;
  const angle2 = angleBetweenCenters - u1 - (spread - u1) * v;
  const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v;
  const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - 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