Interruptible fade-out of DOM elements

Posted on Sun 30 August 2015 in Code • Tagged with HTML, JavaScript, DOM, jQuery, animation, promisesLeave a comment

Here’s a nice animated effect you may have seen in some applications, quite often games. An ephemeral UI element is displayed, either in a corner or pretty prominently, and after a short while it slowly fades out to transparency over several seconds:

"Ding" with full opacity
"Ding" after fading out

If you hover your mouse over it, though, it turns opaque right away and stays like that until you move away with the cursor. It then tries to disappear again, lest you stop it once more and restore to full opacity — and so on. Leave it for a moment, though, and it eventually vanishes.

Cool, eh? Wouldn’t it be nice to have a similar mechanism for, say, notifications displayed within a web app? Sure it would be! And it doesn’t even seem all that complicated, so let’s give it a try.

A promising API

Before delving into implementation details, it’s a good practice think for a moment about an API that we’ll expose to the user. Since it looks like a relatively simple problem, we don’t need no classes or frameworks. All that’s necessary is a simple function:

function interruptibleFadeOut(element, options) {
    // ...
}

Surely, however, whoever calls it would like to be able to tell when the animation finishes (e.g. to remove the element they’ve passed from the DOM tree). Simply waiting a predefined number of seconds is not sufficient, because — as described above — the effect could be restarted arbitrary many times through mouse interaction.

We could pass a callback, of course, but the modern JavaScript way of dealing with asynchronicity and concurrency is through promises. Following this pattern, our function shall return a promise that resolves when the effect ends. Disregarding the possible user interaction for now, a minimal implementation of the fade-out alone would simply use the jQuery.fadeOut method and look something like this:

function interruptibleFadeOut(element, options) {
    if (typeof options === 'number') {
        options = {fadeOut: options};
    }

    element = $(element);
    return new Promise(function(resolve) {
        element.fadeOut(options.fadeOut, function() {
            resolve();
        });
    });
}

The constructor of Promise is admittedly a little weird, as it employs a level of abstraction and indirection that is indicative of (more) functional programming languages. You pass it a function that’s invoked immediately with an argument that itself is a function. (Actually two arguments, but we ignore the other one here). That input function, resolve, should be called whenever the new promise “completes”, in whatever sense it’s appropriate for the particular use case.

Here, we’re resolving the promise when our animation ends. This enables callers to react to this in a very straightforward way:

var elem = $('#foo');
interruptibleFadeOut(elem, 400 /* ms */).then(function() {
    elem.remove();
});

As a small aside, the Promise class is a ECMAScript 6 feature, which means it may not be available in all the browsers. The open-source Q library is often used as a compatibility shim, among many other libraries implementing the Promises/A+ recommendation.

Interaction handling

There’s little value in just wrapping an existing jQuery method, so let’s make it more interesting through mouse events. We want hovering on the element to pause the animation, while moving the cursor away should resume it:

return new Promise(function(resolve) {
    element.on('mouseenter', function() {
        element.stop(true);  // true == discard remaining animation stages
        element.css('opacity', 1.0);
    });
    element.on('mouseleave', function() {
        element.fadeOut(options.fadeOut, function() {
            resolve();
        });
    });

    element.fadeOut(options.fadeOut, function() {
        resolve();
    });
});

That will mostly work even with the above code, but there are several problems that need to be addressed immediately:

  • The mouseenter and mouseleave event handlers are should only fire while the fade-out effect is being played. Currently, they “leak”, and remain bound even after it finishes, interfering with given DOM element for as long as it exists.
  • The whole concept of hovering is contingent upon the user having a pointing device. In today’s mobile world, this excludes a significant fraction of clients. We need an alternative interaction pattern for touchscreen devices, and one possible option is restart the effect on click/tap.
  • There is a fair amount of repetition here, and it will only get worse once the previous problems are dealt with.

Cleaning up

A correct (and cleaner) version is quite a bit longer, but shouldn’t be too hard to follow:

return new Promise(function(resolve) {
    // Event handlers used by the effect.
    var events = {
        mouseenter: function() { interruptAnimation(); },
        mouseleave: function() { delayedFadeOut(); },
        click: function() {
            interruptAnimation();
            delayedFadeOut();
        },
    };
    Object.keys(events).forEach(function(name) {
        element.on(name, events[name]);
    });

    // Helper functions.
    var interruptAnimation = function() {
        element.stop(true);
        element.css('opacity', 1.0);
    };
    var delayedFadeOut = function() {
        if (options.delay) {
            element.delay(options.delay);
        }
        element.fadeOut(options.fadeOut, function() {
            Object.keys(events).forEach(function(name) {
                element.off(name, events[name]);
            });
            resolve();
        });
    };

    // Start the animation.
    if (options.fadeIn) {
        element.fadeIn(options.fadeIn);
    }
    delayedFadeOut();
});

The main part is of course the delayedFadeOut function. It is invoked right at the beginning, as well as whenever the animation has to be started again.

We also put the event handlers in a “map” (JavaScript object). This makes it easy to initially bind them to the element, and — more importantly — unbind them when the effect ends.

As a final touch, we also allow the caller to specify how long the element should remain at its full opacity before it starts to fade out (delay), and to optionally add a fadeIn stage at the very beginning to make everything look a little nicer. (I recommend no more than 300-400ms for that part, though).

Practical example

A complete code sample for the interruptibleFadeOut function can be seen in this gist. To use it in practice, you’d most likely need at least a little bit of CSS and additional JavaScript to position the element appropriately:

<div id="toast">Hello, world!</div>
#toast {
    position: fixed;
    display: none;

    /* These are arbitrary */
    top: 100px;
    width: 80%;
    height: 50px;
}
var toast = $('#toast');
toast.offset({
    left: Math.round($(window).width() - element.outerWidth() / 2),
});
toast.show();
interruptibleFadeOut(toast, { fadeIn: 200, delay: 1000, fadeOut: 3000 })
    .then(function() { toast.remove() });

Styling it to look like a level-up ding is left as an exercise for the reader :)

Continue reading