javascriptcssscrollparallax

How to CSS parallax scroll by updating background-position value with JavaScript?


In CSS, using JavaScript, how can different Y position percentages be assigned to layered background images so they update as the page scrolls?

<style>
  body {
    background-image: 
      url(img/bg-pattern-01.png),
      url(img/bg-pattern-02.png),
      url(img/bg-pattern-03.png);
    background-position:
      center 45%,
      center 50%,
      center 55%;
  }
</style>

So in the above example the percentages (45%, 50%, 55%) would not be static values, but instead dynamically influenced by the page's vertical scroll position. Probably a mix or averaged blend of the actual scroll percentage.


Solution

  • Easy parallax scrolling, relative to the page top and bottom

    My question received an answer (which I had accepted), but that contributor has since deleted their answer. It was a helpful answer, and it demonstrated one possible approach that works. After looking around at some other answers here, I was able to refine the methods and put together what I was envisioning:

    A multi-layered parallax scrolling background that displays reliably at any window size, and can be contained all within the HTML BODY element. (No need for extra DIVs.) This solution uses jQuery - love it or not, it proved very useful for this case. Of course, there's no technical reason this couldn't be recreated in vanilla JavaScript if somebody really desired to do so. The code is not very complex.

    Concept

    A multi-layered background is designed and CSS styling is specified, including background-position. This dictates how the background is to appear on page load. Then the script subtracts a pixel value from the top or bottom position for each layer, depending on whether it's an upper part of the design or a lower one.

    Background images used in this demo, and their respective values:

    image background
    -repeat
    background
    -size
    background
    -position
    (initial)
    background
    -position
    (scripted)
    background
    -blend
    -mode
    perceptual
    layer *
    repeat-x 100vw center bottom center bottom -${(scrollBtm * 0.9)}px lighten lay1:btm
    (nearest)
    repeat-x 100vw center bottom center bottom -${(scrollBtm * 0.6)}px normal lay2:btm
    src repeat 33vw center top center top -${(scrollTop * 0.3)}px lighten particle anim **
    repeat-x 100vw center top center top -${(scrollTop * 0.2)}px normal lay3:top
    repeat-x 100vw center bottom center bottom -${(scrollBtm * 0.2)}px multiply lay3:btm
    no-repeat cover center center center center
    (not scripted)
    normal
    (always)
    lay4
    (farthest)

    * = "Perceptual layer" refers to how the layers in the design are perceived to be ordered, from the viewer's perspective. It's not a technical term, just how I kept track of stacking. Use whatever labels work for you.

    ** = The GIF of animated particles was an afterthought and has no "correct" way to be ordered. Hence no "perceptual layer" number assigned. It happened to look good between lay2 and lay3.

    The main thing to note about this approach is the simplicity of using scrollTop and scrollBottom functions to offset the backgrounds from the top and bottom of the page. Any given perceptual layer (such as lay3) can have both a top and bottom image.

    The script makes layers move away from (- amount px) the top or bottom of the page when scrolled.

    Adjust the scroll rate multiplier (* range 0.0 to 1.0 ) to set the rate of movement relative to the scrolling. Perceived distance will be nearer with higher values (0.9), and farther with lower values (0.1). Image layers sahring the same "perceptual layer" should be given the same scroll rate in order to appear to be the same distance away.

    While not necessary for every kind of design, the background-blend-mode property is utilized in the example to illustrate how useful it can be for achieving even more interesting results.

    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Parallax Scroll</title>
      <link rel="stylesheet" href="css/styles.css">
      <style type="text/css">
    
        body{
          margin: 0;
          height: 150vh; /* ensure enough content to allow scrolling */
          background-repeat: 
            repeat-x,
            repeat-x,
            repeat, /* particle GIF */
            repeat-x,
            repeat-x,
            no-repeat;
          background-size: 
            100vw,
            100vw,
            33vw, /* particle GIF */
            100vw,
            100vw,
            cover;
          background-position: 
            center bottom, /* lay1:btm (nearest)  */
            center bottom, /* lay2:btm  */
            center top, /* particle GIF */
            center top, /* lay3:top  */
            center bottom, /* lay3:btm  */
            center center; /* lay4 (farthest, unscripted, covers page ) */
          background-image: 
            url(https://i.sstatic.net/DdMld.png),
            url(https://i.sstatic.net/6Z2KU.png),
            url(https://i.sstatic.net/p7RgF.gif),
            url(https://i.sstatic.net/wwgc9.png),
            url(https://i.sstatic.net/L2XPa.png),
            url(https://i.sstatic.net/0p29i.gif);
          background-blend-mode:
            lighten, /* gives the foreground crystals a subtle bright transparent effect against the darker ones on lay2 */
            normal, /* lay2 darker (more purple) crystals - 'normal' blending is already perfect here */
            lighten, /* particle FX layer must use lighten (its background is black, no alpha) */
            normal, /* 'multiply' would be alright, but in small browser windows overlap occurs with layer below */
            multiply, /* since the bottom layer is very bright, multiply results in a subtle color change when scrolled */
            normal /* always 'normal' for the lowest layer in the stack */
        }
    
      </style>
      <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
    </head>
    <body>
    
    <script type="text/javascript">
    // adds scrollBottom function | thanks: https://stackoverflow.com/a/30127031
    $.fn.scrollBottom = function(scroll){
      if(typeof scroll === 'number'){
        window.scrollTo(0,$(document).height() - $(window).height() - scroll);
        return $(document).height() - $(window).height() - scroll;
      } else {
        return $(document).height() - $(window).height() - $(window).scrollTop();
      }
    }
    
    // defines calcView (main background scrolling function)
    var calcView = function() {
      var scrollTop = Math.floor( $(document).scrollTop() ); // dist from elem's top to its topmost visible content
      var scrollBtm = Math.floor( $(document).scrollBottom() ); // relies on jQuery plugin, defined above
    
      $('body').css({
        'background-position' : `
          center bottom -${(scrollBtm * 0.9)}px, /*lay1:btm nearest*/
          center bottom -${(scrollBtm * 0.6)}px, /*lay2:btm*/
          center top -${(scrollTop * 0.3)}px, /*particle GIF*/
          center top -${(scrollTop * 0.2)}px, /*lay3:top (shared rate)*/
          center bottom -${(scrollBtm * 0.2)}px, /*lay3:btm (shared rate)*/
          center center /*unscripted*/
        `
      });
    
      // below is optional - to verify scroll distances in the console
      //console.log(
      //  "scrollTop = " + scrollTop + "px from top" +
      //  "\nscrollBtm = " + scrollBtm + "px from bottom"
      //);
    }
    
    // binds function to multiple events | thanks: https://stackoverflow.com/a/2534107/2454914
    $(document).ready(function(){ // once document is ready, listen for the following...
      $(document).on('scroll', calcView); // do calcView function if document is scrolled
      $(window).on('resize', calcView); // do calcView function if window is resized
    });
    
    </script>
    </body>
    </html>

    After running the code snippet, clicking Full Page is recommended to perceive the full effect.

    Notes on image optimization: imgur (the host of the images in this demo) currently does not support WebP, but if used the file size can be greatly reduced - in my tests WebP averaged a minuscule 8% the size of the PNG and GIF still backgrounds. The total size of all still images when exported as WebP was merely 193KB. The single animated GIF was much more costly at 254KB. While I don't want to get sidetracked on a tangent about optimization, it's something to be mindful of when designing full-page backgrounds, especially in multiple layers. The main takeaway is that if you use efficient compression (WebP, AVIF) wherever possible, and restrict inefficient formats (animated GIF) to small dimensions, the image file sizes for your final design should not be prohibitive.