January 4, 2017

> Scroll Advert

svg tricks


The trick to reveal an SVG path on scroll is relatively well-known. Every SVG path comes with two useful attributes: stroke-dasharray, which determines the dash-gap pattern of the path (dotted or dashed line, how long the dashes are, how long the gaps are, etc.), and stroke-dashoffset, which controls the offset of the pattern specified by stroke-dasharray.

Now imagine that our dash-gap pattern consists in a dash as long as the path itself, followed by a gap also as long as the path. Depending on the offset we choose, we can either have the dash fully covering the path, making said path visible, or we can place the gap over the path, hiding it entirely. Anything between those two extremes yields a partial reveal of the path.

Using this trick, we adjust the dash offset proportionally to the scrolling fraction of the window in order to create a smooth scroll-based reveal. The more the visitor scrolls, the more she sees from the path.

A few months ago, I decided to play around with this technique and see how far it could be pushed. The result was showcased here for a while, before moving to GitHub pages. (Unfortunately, fancy self-promotion splash pages come with an expiry date – or so I believe.)

The feedback I got from it was… well, I guess it was mixed. But it was the good kind of mixed. What I mean is that everybody I showed the page to seemed to either love it or be really skeptical. Polarising responses are good (nice article by the way – go read it). Funnily enough, the comments were strongly segregated by age. Young people found it cool, older people had a hard time figuring out what the deal was and why they should waste half a minute of their life scrolling down (… because it’s fun?). I won’t dwell on feedback, but eventually we also build that stuff for people, so their opinion kind of matters. Right?

So, how do we build that kind of animation? Sit tight, I’ll tell you everything.

As you can see, a fair bit happens during scrolling, but obviously not everything happens at the same time. We should be able to specify a start and an end scrolling fraction between which we want the animation to take place, and get some kind of ‘relative scroll fraction’ based on those boundaries, instead of using the whole window. Let’s transform the total scroll fraction into a relative measure:

function getTotalScroll() {
    return (document.documentElement.scrollTop + document.body.scrollTop) / 
           (document.documentElement.scrollHeight - document.documentElement.clientHeight);
}

function getRelativeScroll(startFraction, endFraction) {
    var totalScrollFraction = getTotalScroll();
    var relativeScrollFraction = (totalScrollFraction - startFraction) / 
                                 (endFraction - startFraction)
    return Math.clip(relativeScrollFraction, 0, 1);
}

Now, we need a way to gradually move from any initial to any final (numerical) property value based on relative scroll fraction. The idea here is to linearly interpolate (and assign) a property value using a user-defined setter function. That way, any property becomes animatable, as long as it is numerical.

function changeOnScroll(selector, scrollFractions, values, setValue) {
    var object = document.querySelector(selector);
    var scrollFraction = getRelativeScroll(scrollFractions[0], scrollFractions[1]);

    if (scrollFraction > 0 && scrollFraction < 1) {
        var newValue = values[0] + scrollFraction * (values[1] - values[0]);
        setValue(selector, newValue);
    }
}

Note that while this approach works well, it requires the user to write a new function for every new property, even when the logic remains essentially the same. Not super convenient, but it does the job.

For instance here is how we animate the opacity of an element:

function setOpacity(selector, opacity) {
    document.querySelector(selector).setAttribute('opacity', opacity);
}

window.addEventListener("scroll", function(e) {
    changeOnScroll('#my-selector', [0.07, 0.1], [0, 1], setOpacity);
});

A more particular case is the reveal (or hiding) of paths. All paths have a set direction, which in turn determines from which end the animation starts. To be able to change the direction of animation without re-generating the path, we have to tweak the basic dash-offset code by addind a direction parameter, and create two setter functions (one for each direction).

function changeDashOffset(selector, value, direction) {
    var path = document.querySelector(selector);
    var pathLength = path.getTotalLength();
    var drawLength = pathLength * value;

    path.style.strokeDashoffset = pathLength - (direction * drawLength);
}

//setters
function revealPath(selector, value) {
    changeDashOffset(selector, value, 1);
}


function revealPathReverse(selector, value) {
    changeDashOffset(selector, value, -1);
}

The last non-standard parameter we should handle is colour. Because colour is in fact three values bundled into one, it requires a separate interpolation for each colour channel:

function getColorMix(color1, color2, factor) {
    var rgb1 = hexToRgb(color1);
    var rgb2 = hexToRgb(color2);
    var newRgb = [];
        
    for (var i = 0; i < 3; i ++) {
        newRgb.push(rgb1[i] + factor * (rgb2[i] - rgb1[i]));
    }

    return rgbToHex(newRgb);
}

function colorOnScroll(selector, scrollPositions, colors) {
    var object = document.querySelector(selector);
    var scrollFraction = getRelativeScroll(scrollPositions[0], scrollPositions[1]);

    if (scrollFraction > 0 && scrollFraction < 1) {
        object.style.fill =  getColorMix(colors[0], colors[1], scrollFraction);
    }
}

(Hex/RGB conversion functions recklessly stolen from StackOverflow.)

And that’s essentially all we need to build our animations. To give you an idea, here is how the lightbulb animation starts:

window.addEventListener("scroll", function(e) {
    // bulb appears
    changeOnScroll('#lightbulb', [0.03, 0.04], [30, 18], setRadius);
    changeOnScroll('#light-fitting', [0.03, 0.07], [0, 1], revealPath);
    changeOnScroll('#lightbulb', [0.06, 0.08], [18, 21.6], setRadius)
    changeOnScroll('#top-shadow-straight', [0.07, 0.1], [0, 1], setOpacity);
    changeOnScroll('#bottom-shadow-straight', [0.07, 0.1], [0, 1], setOpacity);
    colorOnScroll('#lightbulb', [0.1, 0.12], ['#877F5C', '#F89406'])
    [...]

I left some details on the side, but the code is on GitHub, so if you are curious about the whole thing and want to dig into it, feel free to have a look.

Also! I refactored the code, fixed the bits I didn’t like (no more setter and separate colour functions), and created a nifty little library out of it. It’s called SVG-Scroll and it’s on NPM. I purposely kept it as simple as I could. It doesn’t require any third party dependencies and it’s complete enough to recreate everything on my promotion page. If you are thinking about making something similar, go check it out (no pun intended)!

Comments? Send me a tweet or an email!