<main>
  <svg id="view">
    <g id="path-layer"></g>
  </svg>
</main>
body {
  background: #222;
}

main {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

#view {
  width: 100%;
  height: 100%;
  background: #222;
}

.repeller {
  fill: orange;
  fill-opacity: 0.1;
  stroke: orange;
  stroke-opacity: 0.8;
}

.point {
  fill: #5a5a5a;
  
  &.first {
    fill: orange;
  }
}

.path {
  stroke: #bbb;
  stroke-width: 2;
  fill: none;
}

#path-layer {
  pointer-events: none;
}
View Compiled
console.clear();
var log = console.log.bind(console);


//
// POINT
// ========================================================================
class Point {
    
  endX = 0;
  endY = 0;
  
  radius  = config.radius  || 5;
  speed   = config.speed   || 20;
  minDist = config.minDist || 0;
  maxDist = config.maxDist || 50;  
  
  element = createSvg("circle", {
    appendTo: parent,
    transforms: true,
    class: `point${!prev ? " first" : ""}`,
    r: this.radius
  });

  transform = this.element._gsTransform;
  firstRun  = true;

  next = null;

  constructor(public parent, public prev, public config) {
    
    _.bindAll(this, "setMove", "startMove");
    
    if (this.prev) prev.next = this;
            
    this.setMove();
    this.firstRun = false;
  }

  get x() { return this.transform.x; }
  set x(x) { TweenLite.set(this.element, { x }); }

  get y() { return this.transform.y; }
  set y(y) { TweenLite.set(this.element, { y }); }

  setMove(updateNext) {
            
    var lastX = this.endX;
    var lastY = this.endY;
    
    if (this.prev) {
            
      var scale = this.firstRun ? 2 : 1;
      
      var signX = randomSign();
      var signY = randomSign();
      
      var min = this.minDist * scale;
      var max = this.maxDist * scale;
      
      this.endX = screen.randomX(this.prev.endX + min * signX, this.prev.endX + max * signX);      
      this.endY = screen.randomY(this.prev.endY + min * signY, this.prev.endY + max * signY);
      
    } else {
      this.endX = screen.randomX();
      this.endY = screen.randomY();
    }
    
    var sameX = fuzzyEqual(lastX, this.endX, 1);
    var sameY = fuzzyEqual(lastY, this.endY, 1);
        
    if (sameX || sameY) {
      _.defer(this.setMove);
      return;
    }  
    
    this.startMove();
  }

  startMove() {
        
    TweenLite.to(this.element, this.duration, {       
      x: this.endX, 
      y: this.endY, 
      ease: Power1.easeInOut,
      onComplete: this.setMove
    });
  }

  get duration() {
    
    if (this.firstRun) return 0.01;    
    var dx = this.x - this.endX;
    var dy = this.y - this.endY;
    var dist = Math.sqrt(dx * dx + dy * dy);
    return dist / this.speed * _.random(0.9, 1.1);
  }

  onHit(x, y) {    
    TweenLite.to(this.element, 0.25, { x, y, onComplete: this.setMove });
  } 
}

//
// PATH
// ========================================================================
class Path {
   
  points = [];
  
  group = createSvg("g", {
    appendTo: parent,    
    class: "path-group"
  });
  
  path = createSvg("path", {
    appendTo: this.group,
    class: "path"
  });

  k = _.isNumber(config.tension) ? config.tension : 1;

  constructor(public parent, public config) {
    
    _.bindAll(this, "render");
    
    var pointProps  = "minDist,maxDist,speed,radius".split(",")
    var pointConfig = _.pick(config, pointProps);
    
    var min  = Math.max(config.minPoints, 3);
    var max  = Math.max(config.maxPoints, 3);    
    var prev = null;
    
    _.times(_.random(min, max), i => {
            
      prev = new Point(this.group, prev, pointConfig); 
      this.points.push(prev);      
    });
            
    TweenLite.ticker.addEventListener("tick", this.render, null, false, 1);
  }

  getRect() { return this.group.getBoundingClientRect(); }

  render() {
        
    var data = _.reduce(this.points, (res, point) => {     
      res.push(point.x, point.y);
      return res;
    }, []);
            
    var size = data.length;
    var last = size - 4;    
    var path = `M${data[0]},${data[1]}`;

    for (var i = 0; i < size - 2; i +=2) {

      var x0 = i ? data[i - 2] : data[0];
      var y0 = i ? data[i - 1] : data[1];

      var x1 = data[i + 0];
      var y1 = data[i + 1];

      var x2 = data[i + 2];
      var y2 = data[i + 3];

      var x3 = i !== last ? data[i + 4] : x2;
      var y3 = i !== last ? data[i + 5] : y2;
      
      var cp1x = x1 + (x2 - x0) / 6 * this.k;
      var cp1y = y1 + (y2 - y0) / 6 * this.k;

      var cp2x = x2 - (x3 - x1) / 6 * this.k;
      var cp2y = y2 - (y3 - y1) / 6 * this.k;

      path += ` C${cp1x},${cp1y},${cp2x},${cp2y},${x2},${y2}`;
    }
        
    this.path.setAttribute("d", path);  
  }
}

//
// REPELLER
// ========================================================================
class Repeller {
    
  vRatio = 0.05;
  
  x = screen.randomX();
  y = screen.randomY();

  endX = this.x;
  endY = this.y;

  element = createSvg("circle", {
    appendTo: parent,
    class: "repeller",
    r: this.radius,
    x: this.x,
    y: this.y
  });

  tracker = VelocityTracker.track(this, "x,y");

  constructor(public parent, public radius = 50) {
       
    _.bindAll(this, "update", "checkHit", "onMove");
          
    document.addEventListener("mousemove", this.onMove);     
    TweenLite.ticker.addEventListener("tick", this.update, null, false, 100);
  }

  get vx() { return this.tracker.getVelocity("x") || 10; }
  get vy() { return this.tracker.getVelocity("y") || 10; }

  update() {
   
    TweenLite.set(this.element, { x: this.x, y: this.y });
    
    var rect1 = this.getRect();
    
    _.forEach(paths, path => {
      
      var rect2 = path.getRect();
      
      if (rectIntersects(rect1, rect2)) {        
        _.forEach(path.points, this.checkHit);
      } 
    });
  }

  checkHit(point) {
    
    var dx = this.x - point.x;
    var dy = this.y - point.y;
        
    var distSq1 = dx * dx + dy * dy;
    var distSq2 = (this.radius + point.radius) ** 2;
        
    if (distSq1 < distSq2) {
      
      var dist1 = Math.sqrt(distSq1);
      var dist2 = Math.sqrt(distSq2);
      
      var angle = Math.atan2(dy, dx);
      
      var x1 = this.x + dist1 * Math.cos(angle);
      var y1 = this.y + dist1 * Math.sin(angle);
      
      var x2 = this.x + dist2 * Math.cos(angle);
      var y2 = this.y + dist2 * Math.sin(angle);
            
      var vx = this.vx * this.vRatio;
      var vy = this.vy * this.vRatio;
      
      var x = point.x + x1 - x2 + vx;
      var y = point.y + y1 - y2 + vy;
      
      point.onHit(x, y);
    }
  }

  getRect() {
    return this.element.getBoundingClientRect();
  }

  onMove(event) {
    this.x = event.pageX;
    this.y = event.pageY;
  }
}

//
// SCREEN
// ========================================================================
class Screen {
  
  width  = window.innerWidth;
  height = window.innerHeight;

  constructor(public space = 100) {    
    window.addEventListener("resize", _.bind(this.onResize, this));
  }

  get minX() { return this.space; }
  get minY() { return this.space; }
  get maxX() { return this.width  - this.space; }
  get maxY() { return this.height - this.space; }

  randomX(min, max) {    
    min = clamp(_.isNumber(min) ? min : this.minX, this.minX, this.maxX);
    max = clamp(_.isNumber(max) ? max : this.maxX, this.minX, this.maxX);
    return _.random(min, max);
  }

  randomY(min, max) {    
    min = clamp(_.isNumber(min) ? min : this.minY, this.minY, this.maxY);
    max = clamp(_.isNumber(max) ? max : this.maxY, this.minY, this.maxY);
    return _.random(min, max);
  } 

  onResize(event) {    
    this.width  = window.innerWidth;
    this.height = window.innerHeight;    
  }
}

//
// HELPERS
// ========================================================================
function createSvg(type, config) {
  var node = document.createElementNS(xmlns, type);
  
  if (config) {    
    if (config.appendTo) config.appendTo.appendChild(node);
    if (config.transforms) _.defaults(config, { x: "+=0" });    
    var attrs = "id,class,cx,cy,r,d,points".split(",");      
    var css   = _.assign(_.omit(config, attrs, "appendTo", "attr", "transforms"));
    css.attr  = _.assign(_.pick(config, attrs), _.omit(config.attr));
    TweenLite.set(node, css);
  }
  return node;
}

function fuzzyEqual(a, b, epsilon = 0.0001) {
  return Math.abs(a - b) < epsilon;
}

function randomSign() {
  return Math.random() < 0.5 ? -1 : 1;
}

function rectIntersects(r1, r2) {
  return !(r1.left + r1.width < r2.left || r1.top + r1.height < r2.top ||
           r2.left + r2.width < r1.left || r2.top + r2.height < r1.top);
}

function clamp(val, min, max) {
  return val < min ? min : val > max ? max : val;
}

//
// RUN
// ========================================================================
var xmlns = "http://www.w3.org/2000/svg";

var numPaths = 3;
var paths = [];

var view = document.querySelector("#view");
var pathLayer = document.querySelector("#path-layer");

var screen = new Screen(100);
var repeller = new Repeller(view, 60);

_.times(numPaths, i => {
  
  var path = new Path(pathLayer, {
    radius: 4,
    tension: 1,
    minDist: 0,
    maxDist: 100,
    minPoints: 5,
    maxPoints: 8,
    speed: 25
  });
  
  paths.push(path);
});





View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. //cdnjs.cloudflare.com/ajax/libs/lodash.js/4.1.0/lodash.min.js
  2. //cdnjs.cloudflare.com/ajax/libs/gsap/1.18.2/TweenMax.min.js
  3. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/ThrowPropsPlugin.min.js