JavaScript shrinking/growing circle transition

TL:DR

- Cross-browser: [**See a working demo here**](http://jsfiddle.net/lthibodeaux/8DSjz/). Well, mostly working... and cross-browser. Could do worse. ;] - Purely CSS3 Solution: [**See a working demo here**](http://jsfiddle.net/lthibodeaux/8DSjz/16/)

How do I even begin to describe this one? It would be a lot easier if the CSS 2 clip standard supported anything besides a "rect" value, namely a "circle" or "ellipse" but... since that doesn't exist, I've done my best to piece something together that will do what you're asking. The caveats are many. One is that this is only going to work on something with a solid color background in the event you wanted the picture to clip to the background. Another is that while I've tried to account for the CSS update timing across browsers, the rendering still isn't "perfect." My initial approach was to simply animate the clip on the image that was getting replaced, but that didn't work due to the way updates were made to the clipping via the easing function in the plugin I located. The final approach is below.

The Approach

The concept is to set the image as a background-image property of a container like a <div> with a background-position of center center, and the position of the container to relative, or anything non-static. The next is to generate the clipping elements as children of the container. The first is a position: absolute clipping circle image of the color of your background, either transparent PNG or GIF (I prefer the former), and the next four are divs, also with absolute positions that have left, right, top, and bottom attributes set to 0 for each of the respective sides they will clip. The idea is to animate the top, left, width, and height of the clipping circle image and synch up the width and height of the clipping divs using the step callback option of the .animate() call by matching them to the current left and top values. Between animations, you change the background-image of the container to the new image and then start the animation back in the opposite direction.

This required a little finessing in IE7, 8, and Webkit browsers as the animation clipped much more cleanly in Firefox and IE9. This would be the adjust variable you'll see in the working demo.

The sample code is below:

The Markup

<div class="imageContainer image1">
    <img class="clip" src="clipCircle.png" />
    <div class="top fill"></div>
    <div class="left fill"></div>
    <div class="right fill"></div>
    <div class="bottom fill"></div>
</div>

The CSS

div.imageContainer
{
    background-position: center;
    width: 300px;
    height: 300px;
    position: relative;
}

img.clip
{
    width: 100%;
    height: 100%;
    position: absolute;
}

div.fill
{
    position: absolute;
    background-color: White;
}

div.left, div.right
{
    height: 100%;
    top: 0;
    width: 0;
}

div.left
{
    left: 0;
}

div.right
{
    right: 0;
}

div.top, div.bottom
{
    width: 100%;
    left: 0;
    height: 0;
}

div.top
{
    top: 0;
}

div.bottom
{
    bottom: 0;
}

The Script

var speed = 1000;

$clip = $("img.clip");

$clip.animate({
    top: $clip.parent().height() / 2,
    left: $clip.parent().width() / 2,
    width: 0,
    height: 0
}, {
    duration: speed,
    step: function(now, fx) {
        switch (fx.prop) {
        case "top":
            $("div.top").css("height", now);
            $("div.bottom").css("height", now + adjust);    
            break;
        case "left":
            $("div.left").css("width", now);
            $("div.right").css("width", now + adjust);
        }
    },
    complete: function() {
        $(this).parent().addClass("image2");

        $(this).animate({
            top: 0,
            left: 0,
            width: $clip.parent().width(),
            height: $clip.parent().height()
        }, {
            duration: speed,
            step: function(now, fx) {
                switch (fx.prop) {
                case "top":
                    $("div.top").css("height", now);
                    $("div.bottom").css("height", now + adjust);    
                    break;
                case "left":
                    $("div.left").css("width", now);
                    $("div.right").css("width", now + adjust);
                }
            },
            complete: function() {
                $("div.imageContainer > *").removeAttr("style");
            }
        });
    }
});

EDIT:

The CSS3 Solution

When cross-browser compatibility is less of a concern, CSS3 is an option (although I'd probably suggest seeing what can be done with the new HTML5 Canvas for this kind of animation). There are a couple things to note:

  • The image must be inside a container in order to allow us to clip toward its center rather than its top left corner.
  • The border-radius attribute will not clip the child images inside a container. For this reason, the image must become the background-image attribute of the container.
  • jQuery does not currently animate border-radius correctly. You can either replace the current jQuery animate functionality for that attribute or build a custom border-radius animation object to make jQuery more well-behaved. I have opted for the latter. Each corner's border-radius must be animated separately.
  • The animation in or out consists of two separate segments, and as a result the "linear" easing function is probably best used for cleanest results.

The method is commented inline below:

The Markup

<div class="imageContainer image1">
</div>

The CSS

div.imageContainer
{
    background-position: 0px 0px;
    background-repeat: no-repeat;
    width: 300px;
    height: 300px;
    position: absolute;
    top: 0;
    left: 0;
}

div.image1
{
    background-image: url(/images/myFirstImage.png);
}

div.image2
{
    background-image: url(/images/mySecondImage.png);
}

The Script

// Total animation speed in or out will be speed * 1.5
var speed = 600;

// Store a reference to the object to be clipped
var $clip = $("div")

// A function to build a mapping object for border radius parameters
var buildRadiusObj = function(value) {

    // Dimension an option object
    var opts = {};

    // Use specialized Mozilla CSS attributes when needed
    var attributes = $.browser.mozilla ?
        ["-moz-border-radius-topleft",
         "-moz-border-radius-bottomleft",
         "-moz-border-radius-topright",
         "-moz-border-radius-bottomright"] :
        ["border-top-left-radius",
         "border-bottom-left-radius",
         "border-top-right-radius",
         "border-bottom-right-radius"];

    // Build the option object
    $.each(attributes, function(i, key) {
        opts[key] = value;
    });

    // Return the result
    return opts;
}

$clip.animate(buildRadiusObj($clip.width() * 0.5), {    // Animate the border radius until circular
    duration: speed * 0.5,
    easing: "linear"
}).animate({                                            // Resize and reposition the container
    width: 0,
    left: $clip.width() / 2,
    height: 0,
    top: $clip.height() / 2
}, {
    duration: speed,
    easing: "linear",
    step: function(now, fx) {                           // Synch up the background-position
        if (fx.prop == "top") {
            $(this).css("background-position", "-" + $(this).css("top") + " -" + $(this).css("left"));
        }
    },
    complete: function() {                              // Swap the image
        $(this).addClass("image2");
    }
}).animate({                                            // Restore position and size
    width: $clip.width(),
    left: 0,
    height: $clip.height(),
    top: 0
}, {
    duration: speed,
    easing: "linear",
    step: function(now, fx) {                           // Synch the background-position
        if (fx.prop == "top") {
            $(this).css("background-position", "-" + $(this).css("top") + " -" + $(this).css("left"));
        }
    },
    complete: function() {                              // Remove inline styles but reapply border-radius
        $(this).removeAttr("style").css(buildRadiusObj($clip.width() * 0.5));
    }
}).animate(buildRadiusObj(0), {                         // Restore border-radius to block
    duration: speed * 0.5,
    easing: "linear",
    complete: function() {
        $(this).removeAttr("style");                    // Remove inline styles
    }
});

Again, the demo is located here.