Search code examples
javascripthtmlcssparallax

Parallax - Element Glitching


So I've been trying to wrap my head around this neat effect called Parallax. Where basically the background scrolls slower than the foreground elements.

I've found this new "trick" which is working. Changing the top property to create the parallax effect, as the scrolling goes.

The issue...

So, for performance purposes and lifting the stress from the CPU when the element is not inside the user's viewport, I've created an if statement, which checks if the top position is more than 300px. If it is, it overwrites everything and sets the top property back to 0, so it won't keep increasing it for no reason.

Now, just scroll for a bit. See how, as the red div comes over the white one, the white one stutters? Looking in the DOM inspector, I see that the if statement is freaking out, setting the top property to 0px even if it's not more than 300px. Halp.

While we're at it, I'd love to see more suggestions regarding parallax effects. I've seen a few answers already regarding this effect, but they seem... overly complicated for me. I know there are better ways to do this, I know there are.

And also, It would be greatly appreciated if there were no jQuery answers. Thanks.

var txtfirst = document.getElementById("txtfirst");


window.onscroll = function(){
	var ypos = window.pageYOffset;
  txtfirst.style.top = ypos * 0.4 + "px";


if(txtfirst.style.top > '300px'){
	txtfirst.style.top = '0px';
}
}
html, body {
 margin: 0;
 padding: 0;
 width: 100%;
 height: 100%;
}

.text-first {
  display: flex;
  text-align: center;
  justify-content: center;
  align-items: center;
  font-size: 32px;
  font-family: Arial;
  color: gray;
  width: 100%;
  height: 500px;
  position: relative;
}

.foreground-red {
  width: 100%;
  height: 600px;
  background-color: red;
  display: flex;
  justify-content: center;
  align-items: center;
  font-family: Arial;
  color: gray;
  font-size: 32px;
}

.spacer { /*for scrolling purposes*/
  width: 100%;
  height: 1000px;
}
<div class="text-first" id="txtfirst">THIS IS SOME TEXT</div>
<div class="foreground-red">THIS SHOULD GO ABOVE</div>
<div class="spacer"></div>


Solution

  • Your application most likely (you only provided selected snippets, I assume) does not work at least because you are comparing text when you want to be comparing numbers, in your attempt at optimization:

    txtfirst.style.top > '300px'
    

    The above will not behave like what you'd expect it to. Every property of the Element::style property (e.g. txtfirst.style in your case) is a text string, not a number. A test like "50px" < "300px" does not compare whether 50 is less than 300, it compares the text values lexicographically.

    If you actually want to compare the amount of pixels, you can use parseInt function to convert a value like 50px to a number, 50. Your test will then look as follows:

    parseInt(txtfirst.style.top) < 300
    

    Now, what follows is a number of problems with your approach to solving this and suggested solutions, since you are interested in suggestions.

    Using inline styles is problematic in general (subjective)

    • Inline styles have the highest precedence in CSS, which can be problematic in cases where the user has their own style sheets, as properties set in those will be ignored in favor of properties set inline.

    • Reading properties of inline style back assuming that would be the actual used value, is just plain wrong. Inline style object tracks assigned values, not computed or used values. The Window::getComputedStyle(element) function, on the other hand, retrieves computed style for the element.

    Solution? Reading properties using getComputedStyle and writing them directly to a preferred (or empty, if so desired) stylesheet (document.styleSheets, reflecting all link rel=stylesheet and style elements):

    function rule(selector) {
        var sheet = document.styleSheets[0];
        return Array.prototype.find.call(sheet.cssRules, rule => rule.selectorText == selector);
    }
    var txtfirst = document.getElementById("txtfirst");
    window.onscroll = function() {    
        var ypos = window.pageYOffset;
        var style = rule(".text-first").style;
        style.top = ypos * 0.4 + "px";
        if(parseInt(getComputedStyle(txtfirst).top) > 300) {
            style.top = "0px";
        }
    }
    

    The rule function above returns the CSS rule (one containing the set CSS properties) with matching selector (e.g. .text-first or html, body) from the first found stylesheet (you only have one). The style property of a a rule refers to an object which contains all CSS properties set in the rule. It behaves the same as the inline style object. Observe that you aren't using inline styles anywhere above, you write to the stylesheet object (as initialized by the <style>...</style> fragment of your document) and read back computed values.

    Fixing problems with using scroll event for animation

    First of all, did you know that older versions of iOS did not fire the scroll event as you scrolled? That would stop your parallax effect dead in its tracks, as a single scroll event would be fired after the user stops scrolling. This has to do with the way browsers do page scrolling -- to achieve smooth page scrolling animation using constrained mobile CPU resource, running JavaScript code courtesy of a scroll event handler 60 times per second was just deemed too generous an offer, and Apple instead went for the controversial solution, occupied with good UX as they are.

    Anyway, what to do then if not use scroll event? You could use the good old setInterval:

    function rule(selector) {
        var sheet = document.styleSheets[0];
        return Array.prototype.find.call(sheet.cssRules, rule => rule.selectorText == selector);
    }
    var txtfirst = document.getElementById("txtfirst");
    var old_window_pageYOffset = window.pageYOffset;
    setTimeout(function() {    
        var ypos = window.pageYOffset;
        if(ypos != old_window_pageYOffset) return;
        old_window_pageYOffset = ypos;
        var style = rule(".text-first").style;
        style.top = ypos * 0.4 + "px";
        if(parseInt(getComputedStyle(txtfirst).top) > 300) {
            style.top = "0px";
        }
    }, 1000 / 60);
    

    What the above does is makes sure a function is called 60 times per second for the entire lifetime of your page, but checks on every invocation if the scroll position of the window has changed since last invocation, invoking the old code only if it has, and doing nothing otherwise. This obviously does not use the scroll event at all. All this said, newer iOS releases have since reverted the behavior and the scroll event is fired with every change of the scrolling position. Meaning you may simply want to use that as baseline and depend on the event instead of setInterval. A free benefit of the latter is that you control the rate at which your parallax effect runs.

    I can also suggest using requestAnimationFrame, which is more "intelligent" than setInterval in that the user agent will not invoke your code if it deems animation unnecessary, for example if the entire page tab isn't visible or interactive with the user at the moment. Rest assured your animation will run "when needed":

    function rule(selector) {
        var sheet = document.styleSheets[0];
        return Array.prototype.find.call(sheet.cssRules, rule => rule.selectorText == selector);
    }
    var txtfirst = document.getElementById("txtfirst");
    var old_window_pageYOffset = window.pageYOffset;
    requestAnimationFrame(function() {    
        var ypos = window.pageYOffset;
        if(ypos != old_window_pageYOffset) return;
        old_window_pageYOffset = ypos;
        var style = rule(".text-first").style;
        style.top = ypos * 0.4 + "px";
        if(parseInt(getComputedStyle(txtfirst).top) > 300) {
            style.top = "0px";
        }
    });
    

    The code above is an okay attempt at the parallax effect, save for minor nitpicks which have little to do with parallax effect alone:

    • I don't use the on*name* family of functions when we have addEventListener. The former is one property for each handler, and there are no guarantees your script is the sole consumer of these properties -- they may already be set by a browser extension. We can argue whether the Web page author has exclusive ownership and access to all properties they can get their hands on, but at least I have explained my rationale. There is no significant drawback known to me for using addEventListener("scroll", function() { ... }) instead.

    • You don't need to use both a class name and an ID for an element to refer to it. document.querySelector(".text-field") will return the first available element that has "text-field" among its list of class names.

    I've saved the best for last -- Pure CSS Parallax Websites goes through achieving (although not without some small hacks for browsers with bugs) desired effect without any JavaScript at all, relying on the CSS perspective property and some others. It also mentions some of the same things I've warned about above, things that I have attempted to circumvent and explain.

    If you don't want to read (and understand) documentation, I suggest you resort to using a convenient abstraction -- a plugin, a framework, a library or something to that end that will save you from having to grok the intricacies of this. Modern CSS and compliant browser model are complex enough for these solutions to exist and thrive.