CodePen

HTML

            
              <canvas id="canvas"></canvas>
            
          
!

CSS

            
              body { background-color: black; }
            
          
!
? ?
? ?
Must be a valid URL.
+ add another resource
via CSS Lint

JS

            
              // requestAnimationFrame shim
window.requestAnimFrame = (function () {
    return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback) {
        window.setTimeout(callback, 1000 / 60);
    };
})();
// particle "class"
function Particle(x, y, r) {
    this.x = x; // x position
    this.y = y; // y position
    this.r = r; // radius
    //=========
    this.vx = 0; // x velocity
    this.vy = 0; // y velocity
    this.image = null; // this stores the canvas element 
}
Particle.prototype = { 
    init: function () { // could be done in constructor... but I like init functions... 
      // for performance reasons we need to draw the canvas ahead of time for each particle
      // creating radialGradients and fillStyle is expensive. 
        var can = document.createElement('canvas'),
            ctx = can.getContext('2d');
        can.height = can.width = this.r * 2;
        var grad = ctx.createRadialGradient(this.r, this.r, 1, this.r, this.r, this.r);
        grad.addColorStop(0, 'rgba(' + particle_settings.color.r + ',' + particle_settings.color.g + ',' + particle_settings.color.b + ',1)');
        grad.addColorStop(1, 'rgba(' + particle_settings.color.r + ',' + particle_settings.color.g + ',' + particle_settings.color.b + ',0)');
        ctx.fillStyle = grad;
        ctx.beginPath();
        ctx.arc(this.r, this.r, this.r, 0, Math.PI * 2, true);
        ctx.closePath();
        ctx.fill();
        this.image = can;
    },
    update: function () { // the update function controls positioning
        this.vx += particle_settings.gravity_x; // apply gravity 
        this.vy += particle_settings.gravity_y;
        this.x += this.vx; // update the current position
        this.y += this.vy;
        // correct for off screen
        if (this.x + this.r > width) {
            this.x = width - this.r;
        }
        if (this.x - this.r < 0) {
            this.x = 0 + this.r
        }
        if (this.y + this.r > height) {
            this.y = height - this.r;
        }
        if (this.y - this.r < 0) {
            this.y = 0 + this.r;
        }
    },
    attract: function (x, y) { // attraction the 'force' behind it all
        // we need to find the distance from the position passed to the current particle
        var dist_x = this.x - x,
            dist_y = this.y - y,
            dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y);
        var cos = dist_x / dist;
        var sin = dist_y / dist;
      // cheating here, applying some trial and error velocity modifications
        this.vx += -cos * 2;
        this.vy += -sin * 2;
        // once we get X close to the source we need to slow down.
        if (dist < this.r * particle_settings.attraction_influence) {
            this.vx *= .89;
            this.vy *= .89;
        }

    },
  // all particles are rendered to the mcontex (meta context)
    render: function (context) {
        mcontext.drawImage(this.image, this.x - this.r, this.y - this.r);
    }
};
// this takes care of the metaball effect and the colour change
function metabolize(color) {
    var image = mcontext.getImageData(0, 0, width, height), // get the image data
        data = image.data;
    for (var i = 0, l = data.length; i < l; i += 4) { // loop it, rgba is stored in a 1d array so we jump 4 positions at a time
        if (color) { // apply color change
            data[i] = color.r;
            data[i + 1] = color.g;
            data[i + 2] = color.b;
        }
        if (data[i + 3] < 210) { // this value (and general method) was stolen from Loktar ;) 
            data[i + 3] /= 6; // played with this, 6 is good
        }
    }
    context.putImageData(image, 0, 0); // put it to the display canvas
}

function render() { // render loop
    requestAnimFrame(render);
    mcontext.clearRect(0, 0, width, height);
    for (var i = 0, l = particles.length; i < l; i++) {
        particles[i].render();
    }
    metabolize(color_modifier); // color_modifier is contoller in update_time()
}

function update_particles() { // update particles
    var f = forces.slice(0, particles.length); // this is a cheap way to ensure  I never trigger more force points than particles.
    for (var i = 0, l = particles.length; i < l; i++) {
        particles[i].update();
        if (f[i]) { // only trigger force points on the first N particles
            particles[i].attract(f[i].x, f[i].y);
        } else { // the rest can jitter around the number they will change next. 
            var d = new Date(),
                xpos = 0;
            if (d.getSeconds() > 0) {
                xpos = (width / 2) + (width / 3) | 0;
            } else if (d.getMinutes() > 0) {
                xpos = (width / 2) + (width / 8) | 0;
            } else {
                xpos = (width / 2.5) | 0;
            }
            particles[i].attract(Math.random() * (xpos / 2) + (xpos / 2), height);
        }
    }
    setTimeout(update_particles, 1000 / 60);
}

function update_forces() { // this updates the forces
    var image = tcontext.getImageData(0, 0, width, height), // further down we draw the time string to a canvas, now we need the data.
        data = image.data;
    forces = [];
    for (var x = 0; x < width; x += resolution) { //  resolution is how many square pixles we want to 'search' by.
        for (var y = 0; y < height; y += resolution) {
            var offset = x * 4 + y * 4 * image.width; // take into consideration 1d array
            if (data[offset + 3] > 210) { // offset as offset = r, offset + 1 = g, ..b. .. a.
                forces.push({
                    x: x,
                    y: y
                });
            }
        }
    }
    setTimeout(update_forces, 1000 / 60);
}

function update_time() {
    var now = Date.now();
    if (now - ctime > 1000) { // best '1 second' we can get
        ctime = now;
        tcontext.clearRect(0, 0, width, height);
        var t = new Date().toLocaleTimeString(); // this makes thing super easy
        if (!guio.show_ampm) {
            t = t.substring(0, 8); // trim am/pm
        }
        var w = tcontext.measureText(t).width; // need the width of the text
        tcontext.fillText(t, (width - w) / 2, height / 2);
        var d = new Date(); // for color modification
      // 12 / 255 (with padding)
        color_modifier.r = d.getHours() * 20.25 | 0;
      // 60 / 255 (with visible padding)
        color_modifier.g = d.getMinutes() * 4.25 + 60 | 0;
      // 60 / 255 (with padding)
        color_modifier.b = d.getSeconds() * 3.25 | 0;
    }

    setTimeout(update_time, 4); // this needs to happen as fast as the browser will let it
}

function run() { // this is triggered onload
    tcontext.font = "bold 72px Arial"; // set font
  // start update loops
    update_time();
    update_forces();
    update_particles();
    render();
}
// variables
var canvas = document.getElementById('canvas'), // display canvas
    context = canvas.getContext('2d'),
    mcanvas = document.createElement('canvas'), // meta canvas
    mcontext = mcanvas.getContext('2d'),
    tcanvas = document.createElement('canvas'), // time canvas
    tcontext = tcanvas.getContext('2d'),
    height = canvas.height = mcanvas.height = tcanvas.height = 200,
    width = canvas.width = mcanvas.width = tcanvas.width = 600,
    forces = [],
    particles = [],
    particle_settings = {
        gravity_x: 0,
        gravity_y: .5,
        attraction_influence: 50,
        color: {
            r: 0,
            g: 0,
            b: 255
        },
        size: 8
    },
    gui = new dat.GUI(),
    resolution = 4,
    color_modifier = {
        r: 0,
        g: 0,
        b: 0
    },
    ctime = Date.now(),
    guio = {
        show_ampm: false
    };
// create the initial particles to be used in the demo. 555 was a trial and error settlement.
for (var i = 0; i < 555; i++) {
    var particle = new Particle(Math.random() * width, Math.random() * height, particle_settings.size);
    particle.init();
    particles.push(particle);
}
gui.add(guio, 'show_ampm');
run();
            
          
!
Must be a valid URL.
+ add another resource
via JS Hint
Loading ..................