Blog (my web musings)

Find out what's interesting me, get tips, advice, and code gems that don't fit elsewhere.


Search Blog


About a month ago, this CodePen by @Funsella caught my eye while I was browsing the CSS Tricks forum. It works by taking the value of a custom data-background-color attribute, and applying it as the background-color when a section element scrolls to the top of the viewport. All of this is, of course, achieved with JavaScript and jQuery.

I really liked the visual effect but ultimately wanted to work with something a bit more versatile; instead of data- attributes and section elements, I wanted to use classes and apply them to any element. More importantly, I wanted to do it with vanilla JavaScript (no dependancies). I'm sure that if I were to look long and hard enough at Google, I'd find some sort of plugin to do the job, but as this was an exercise in personal development, I took on the coding challenge myself.

In the past I've had great success with When in Viewport and Sloth, but as they're scripts that primarily target elements as they enter the viewport, and the CodePen by @Funsella targets the elements as they are just about to leave the viewport (in other words, as they hit the top of the screen), this was just a further good excuse for me to play around with JavaScript and create something more pertinent to my needs.

getBoundingClientRect() to calculate distance from top

I began by looking into the getBoundingClientRect() method, which returns window coordinates (from upper-left) of a watched element. Typically, all 4 sides of an element will be watched in order to work out if any part of it is within the viewport, but, as I was only really interested in watching the top of the element, I ignored the left, bottom and right properties, leaving this as the basis for my "distance from top" calculation;

elem.getBoundingClientRect().top

There isn't really a calculation for the distance from top in pixels because the above line will already return a pixel value (albeit in fractional pixels, rather than a whole number), but we can work out the distance from top as a percentage like this;

percentage from top = (pixels from top / window height in pixels) * 100

Having an option to express the distance from the top of the viewport as a percentage is very useful because, unlike fixed pixel values, percentages are fluid, and relative to the size of the viewing browser. This is a big bonus in responsive design, but it makes my elementFromTop() function doubly useful because it allows the "distance from top" to be flipped to act like a "distance from bottom" function too. How so? Well, 100% from top is actually the bottom of the viewport, making this a handy script for simple "element entrance" (just in view) logic.

Basic 'Add Class to Element when Scrolled into View' logic

The 1st version of my Add Class to Element when Scrolled into View (Basic) code gave me this;

Demo

Note that I'm using a few helper functions to handle classes; hasClass(), addClass() and delClass(). These helper functions are included in all of the demo pages, so just view the source of those to grab a copy.

Here's the JavaScript for the 1st version of my elementFromTop() function;

function elementFromTop(elem, classToAdd, distanceFromTop, unit) {
    var winY = window.innerHeight || document.documentElement.clientHeight,
        distTop = elem.getBoundingClientRect().top,
        distPercent = Math.round((distTop / winY) * 100),
        distPixels = Math.round(distTop),
        distUnit;
    distUnit = unit == 'percent' ? distPercent : distPixels;
    if (distUnit <= distanceFromTop) {
        if (!hasClass(elem, classToAdd)) { addClass(elem, classToAdd); }
        } else {
        delClass(elem, classToAdd);
        }
    }
// params: element id, class to add, distance from top, unit ('percent' or 'pixels')

I used unique IDs of watched elements while testing, but I quickly rewrote my elementFromTop() function to work with multiple classes (therefore watching multiple trigger-elements, thanks to a loop), and got on with the task of recreating the CodePen by @Funsella.

Recreating the 'Changing background-color while scrolling' demo

The 2nd version of my Add Class to Element when Scrolled into View (Reduced) code looks like this;

Demo

Here's the JavaScript for the 2nd version of my elementFromTop() function;

function elementFromTop(elem, classToAdd, distanceFromTop, unit) {
    var winY = window.innerHeight || document.documentElement.clientHeight,
    elemLength = elem.length, distTop, distPercent, distPixels, distUnit, i;
    for (i = 0; i < elemLength; ++i) {
        distTop = elem[i].getBoundingClientRect().top;
        distPercent = Math.round((distTop / winY) * 100);
        distPixels = Math.round(distTop);
        distUnit = unit == 'percent' ? distPercent : distPixels;
        if (distUnit <= distanceFromTop) {
           if (!hasClass(elem[i], classToAdd)) { addClass(elem[i], classToAdd); }
           } else {
           delClass(elem[i], classToAdd);
           }
        }
    }
// params: element, classes to add, distance from top, unit ('percent' or 'pixels')

The main limitation of this version of the elementFromTop() function is that it adds classes to the same element that is being watched ("watch A and add classes to A" logic). Compare that to the CodePen by @Funsella, which is watching inner section elements, but changing the background-color of a single outer wrapper div. I realised that the only way I could recreate a cross-fading background-colour on a seemingly single background element, was to use fixed position :before pseudo elements on each of the inner section elements;

.section:before { content:''; position:fixed; z-index:-1; top:0; left:0; right:0; height:100vh; transition:background .5s; transform:translate3d(0,0,0) }

Unfortunately, too many fixed position elements (especially faux background wannabes that fill the entire screen... guilty!) cause jank in iOS, which can crash wee mobile browsers. I tried to counter this, and improve performance, by applying position:fixed only when needed - again via the elementFromTop() function, through the addition of the '.bg--fixed' class.

.bg--fixed:before { position:fixed }

This was fine when the page first loaded, and was still fine even after scrolling past half-a-dozen full-screen panels, but the number of fixed position elements continued to increase the further down the page I scrolled. After a few flicks, the jank kicked in big time, so I decided that the best way forward would be to modify the elementFromTop() function yet again...

Refining the 'Changing background-color while scrolling' demo

The 3rd version of my Add Class to Target-Element when Trigger-Element is Scrolled into View (Reduced) code looks like this;

Demo

Here's the JavaScript for the 3rd version of my elementFromTop() function;

function elementFromTop(elemTrigger, elemTarget, classToAdd, distanceFromTop, unit) {
    var winY = window.innerHeight || document.documentElement.clientHeight,
        elTriggerLength = elemTrigger.length,
        elTargetLength, distTop, distPercent, distPixels, distUnit, elTarget, i, j;
    for (i = 0; i < elTriggerLength; ++i) {
        elTarget = document.querySelectorAll('.'+elemTarget);
        elTargetLength = elTarget.length;
        distTop = elemTrigger[i].getBoundingClientRect().top;
        distPercent = Math.round((distTop / winY) * 100);
        distPixels = Math.round(distTop);
        distUnit = unit == 'percent' ? distPercent : distPixels;
        if (distUnit <= distanceFromTop) {
            if (!hasClass(elemTrigger[i], elemTarget)) {
               for (j = 0; j < elTargetLength; ++j) {
                   if (!hasClass(elTarget[j], classToAdd)) { addClass(elTarget[j], classToAdd); }
                   }
               } else {
               if (!hasClass(elemTrigger[i], classToAdd)) { addClass(elemTrigger[i], classToAdd); }                }
           } else {
           delClass(elemTrigger[i], classToAdd);
           if (!hasClass(elemTrigger[i], elemTarget)) {
              for (j = 0; j < elTargetLength; ++j) { delClass(elTarget[j], classToAdd); }
               }
           }
       }
   }
// params:  trigger element, target element class, classes to add to target element, trigger element distance from top, unit ('percent' or 'pixels')
// usage:   elementFromTop(elemTrigger, elemTarget, classToAdd, distanceFromTop, unit);

In the revised code above, the elementFromTop() function can now add classes to a different element than the one(s) being watched ("watch A but add classes to B" logic). With this improvement, a class can now be added to the main outer wrapper div while each section is being watched, which perfectly mimics the CodePen by @Funsella. Plus, I've removed the jank and taken back control of performance on iOS!

Usage ideas

The 3rd and final version of the elementFromTop() function can be put to many uses;

Here's that final demo again to take with you;

Demo