Table of Contents

Parallax Image

This code was written when I was building the second version of my Cure Interactive website and wanted to be able to apply parallax effect on some images, but was not happy with the results of the third party work that I found. However, this code was never used in any of the production pages and remained hidden in the demo pages of that site. Now in 2021, I brought it back to my portfolio page, rewritten in vanilla JavaScript, without jQuery, and addressing some issues that it had. Please read the 2021 Rewrite section for more information on the changes I've made.

Parallax Examples

Here are some examples of the parallax effect in action, with some filler text to make sure the full effect is visible from the bottom of the viewport to the top.

This demo is observed best in a browser with smooth scrolling enabled.

  • Chrome: Enable by going to chrome://flags/#smooth-scrolling
  • Smooth scrolling should be a default feature in Firefox as of 2021.
  • Microsoft Edge: Should be able to be toggled within edge://flags, but may not be accessible or may be hard to get to for some users.

Image at 200% Scale

Image at 200% it's container's size. Default for parallax.

Image at 250% Scale

Image at 250% it's container's size.

Image at 300% Scale

Image at 300% it's container's size.

2021 Rewrite

The changes implmented to port the code from my previous Cure Interactive website over to my new portfolio page are below.

  1. Clean up HTML and CSS for the entier test page, using the new methodologies I have accumulated over the years, such as formatting in ways that are easier to read and Block Element Modifier CSS naming schemes.
  2. Convert the scripts from jQuery to JavaScript, as I no longer like to include large libraries if I do not have to.
  3. Address any bugs that remained in the old version of the code.

Issues Fixed in the Code

Some of the issues fixed are documented below.

  • Images being too small on screens with less height than expected and produing large left and right margins. This was solved by making the minimum image width to be the same as that of the wrapper.
  • My page constains CSS animations whenever breakpoints occur during resizing of the page to Tablet and Phone sizes. Whenever one of these breakpoints was triggered, the width of the page smoothly transitions and causes the content to shift vertically over a time of half of a second. This would cause the parallax images to snap to thier correct position on the next update and looked bad. This was fixed by triggering a delayed update whenever a known breakpoint was crossed while resizing the width of the page. During this delayed update if no other user-invoked updates occur then the image will animate it's transition to it's new location for the short time of the resize animation frame in order to make the viewing of this change more pleasant. This delayed update would fire at a set rate until the end of the known CSS animation time of 500ms. Unfortunatly there is no way to get the animation transition duration from the CSS to the JavaScript, so these values had to be hard coded. To make things easier for individuals seeking to use my work in thier own projects, I have provided a format with and without this addition to the code, as most webpages do not have a breakpoint animation se in thier CSS stylesheets. However, the version with the addition of this fix is easily modifiable and the feature can be disabled with a single hasResizeAnimations variable, with all related code at the bottom of the page with the exception of one callback inside of the calcParallax function.

Known Issues

Currently, the only known issue has to do with when the image's wrapper hight is too small, the image scrolls in the incorrect direction instead of the correct one. I currently do not know why this is occuring and futher investigation will be needed to work out the cause and find a solution. For now as long as the image isnt too big in relation to it's wrapper, the effect seems to work well. Some adjustments may be required depending on the aspect ratio of the image used and the ratio and size of the image's wrapper. For this reason I have limited the height of the wrappers to 250px, but this value does not solve the issue in all cases and a fix still needs to be found.

New Code as of January 2021

New Code as of January 2021
HTML
<div class="parallax"><img src="../my-parallax-img.png"></div>
CSS
.parallax {
  border: 1px solid;
  border-color: rgba(0, 0, 0, 0.25) rgba(127, 127, 127, 0.1) rgba(255, 255, 255, 0.15);
  border-radius: 10px;

  height: 50vh;
  min-height: 250px;
  overflow: hidden;
  position: relative;

  z-index: 1; /* somehow forces border-radius to work? */
}
.parallax img {
  position: absolute;
  left: 50%;
  top: 0px;

  min-width: initial;
  min-height: initial;
  max-width: initial;
  max-height: initial;
  width: auto;
  height: auto; /* overwritten on init */

  transform: translate3d(-50%, 0px, 0);
}
JavaScript Without Animated Resize Logic
function addEvent(evnt, elem, func) {
  if (elem.addEventListener) { // W3C DOM
    elem.addEventListener(evnt, func, false);
  }
  else if (elem.attachEvent) { // IE DOM
    elem.attachEvent('on' + evnt, func);
  }
  else { // No much to do
    elem['on' + evnt] = func;
  }
}

function getCurrentScrollYPosition() {
    // Firefox, Chrome, Opera, Safari
    if (self.pageYOffset) return self.pageYOffset;
    // Internet Explorer 6 - standards mode
    if (document.documentElement && document.documentElement.scrollTop)
        return document.documentElement.scrollTop;
    // Internet Explorer 6, 7 and 8
    if (document.body.scrollTop) return document.body.scrollTop;
    return 0;
}

var parallaxElements = document.getElementsByClassName('parallax');

function calcParallax(recalc) {
  window.requestAnimationFrame(function() {
    var currentScrollYPosition = getCurrentScrollYPosition(), windowHeight = window.innerHeight;

    for (var i = 0; i < parallaxElements.length; i++) {
      var wrapTopDist = parallaxElements[i].offsetTop,
          wrapHeight  = parallaxElements[i].offsetHeight,
          wrapRange   = windowHeight + wrapHeight,
          wrapPos = Math.max( ( wrapTopDist - currentScrollYPosition ) + wrapHeight, 0 ),
          isVisible = wrapPos <= wrapRange && wrapPos >= 0;

      if(recalc || isVisible) {
        var parallaxElement = parallaxElements[i],
            img = parallaxElement.querySelectorAll('img')[0],
            imgHeight = img.offsetHeight,
            amt = Math.min(wrapPos, wrapRange) / wrapRange;

        if(recalc) {
          var imgAspectRatio = img.naturalHeight / img.naturalWidth,
              wrapWidth = parallaxElements[i].offsetWidth,
              newHeight = Math.max( Math.round( wrapHeight * ( 1 + ( parallaxElement.getAttribute("data-scale") ? parseFloat( parallaxElement.getAttribute("data-scale") ) : 1 ) ) ), ( wrapWidth * imgAspectRatio ) );

          if(imgHeight != newHeight) {
            imgHeight = newHeight; // keep from grabbing offsetHeight twice during recalcs

            img.style.height = imgHeight + 'px';
          }
        }

        if ( isVisible ) img.style.transform = 'translate3d(-50%, ' + ( -1 * ( Math.abs( imgHeight - wrapHeight ) * amt ).toFixed( Math.max( window.devicePixelRatio - 1, 0 ) ) ) + 'px, 0)';
      }
    }
  });
}

function checks() {
  calcParallax(true, false);
}

function scrollChecks() {
  calcParallax(false, false);
}

addEvent('ready', document, function() { checks(); });
addEvent('load', window, function() { checks(); });
addEvent('resize', window, function() { checks(); });
addEvent('orientationchange', window, function() { checks(); });
addEvent('scroll', window, function() { scrollChecks(); });
JavaScript With Animated Resize Logic
function addEvent(evnt, elem, func) {
  if (elem.addEventListener) { // W3C DOM
    elem.addEventListener(evnt, func, false);
  }
  else if (elem.attachEvent) { // IE DOM
    elem.attachEvent('on' + evnt, func);
  }
  else { // No much to do
    elem['on' + evnt] = func;
  }
}

function getCurrentScrollYPosition() {
    // Firefox, Chrome, Opera, Safari
    if (self.pageYOffset) return self.pageYOffset;
    // Internet Explorer 6 - standards mode
    if (document.documentElement && document.documentElement.scrollTop)
        return document.documentElement.scrollTop;
    // Internet Explorer 6, 7 and 8
    if (document.body.scrollTop) return document.body.scrollTop;
    return 0;
}

var parallaxElements = document.getElementsByClassName('parallax');

function calcParallax(recalc, smooth) {
  window.requestAnimationFrame(function() {
    var currentScrollYPosition = getCurrentScrollYPosition(), windowHeight = window.innerHeight;

    for (var i = 0; i < parallaxElements.length; i++) {
      var wrapTopDist = parallaxElements[i].offsetTop,
          wrapHeight  = parallaxElements[i].offsetHeight,
          wrapRange   = windowHeight + wrapHeight,
          wrapPos = Math.max( ( wrapTopDist - currentScrollYPosition ) + wrapHeight, 0 ),
          isVisible = wrapPos <= wrapRange && wrapPos >= 0;

      if(recalc || isVisible) {
        var parallaxElement = parallaxElements[i],
            img = parallaxElement.querySelectorAll('img')[0],
            imgHeight = img.offsetHeight,
            amt = Math.min(wrapPos, wrapRange) / wrapRange;

        if(recalc) {
          var imgAspectRatio = img.naturalHeight / img.naturalWidth,
              wrapWidth = parallaxElements[i].offsetWidth,
              newHeight = Math.max( Math.round( wrapHeight * ( 1 + ( parallaxElement.getAttribute("data-scale") ? parseFloat( parallaxElement.getAttribute("data-scale") ) : 1 ) ) ), ( wrapWidth * imgAspectRatio ) );

          if(imgHeight != newHeight) {
            imgHeight = newHeight; // keep from grabbing offsetHeight twice during recalcs

            img.style.height = imgHeight + 'px';
          }
        }

        if ( isVisible ) img.style.transform = 'translate3d(-50%, ' + ( -1 * ( Math.abs( imgHeight - wrapHeight ) * amt ).toFixed( Math.max( window.devicePixelRatio - 1, 0 ) ) ) + 'px, 0)';

        smoothParallax( img, smooth );
      }
    }
  });
}

function checks() {
  calcParallax(true, false);
}

function scrollChecks() {
  calcParallax(false, false);
}

addEvent('ready', document, function() { checks(); });
addEvent('load', window, function() { checks(); });
addEvent('resize', window, function() { checks(); });
addEvent('orientationchange', window, function() { checks(); });
addEvent('scroll', window, function() { scrollChecks(); });

var hasResizeAnimations = true; // only needed if hendeling resizes with css animations that affect the page content

if ( hasResizeAnimations )
{
  addEvent('resize', window, function() { resizeAnimationCheck(); });
  addEvent('orientationchange', window, function() { resizeAnimationCheck(); });
}

// only needed if hendeling resizes with css animations that affect the page content:
var resizeAnimationRate = 100, // ms per animationUpdateFrame
    resizeAnimationTime = 500, // time of my own page css resize animations
    resizeAnimationRateClamped = Math.max( Math.min( resizeAnimationRate, 1 ), resizeAnimationTime ),
    resizeAnimationFrame = Math.ceil( resizeAnimationTime / resizeAnimationRateClamped );

var resizeAnimationBreakpoints = [ 768, 1024 ], // breakpoints where page css resize animations occur
    lastResizeWidth = window.innerWidth;

function resizeAnimationCheck() {
  var resizeAnimationTriggered = false;

  for ( var i = 0; i < resizeAnimationBreakpoints.length; i++ )
  {
    var resizeAnimationBreakpoint = resizeAnimationBreakpoints[i],
        currentResizeWidth = window.innerWidth;

    // toggle at same time that the media queries do for page resize css animations:
    if ( lastResizeWidth > resizeAnimationBreakpoint && currentResizeWidth <= resizeAnimationBreakpoint ||
         lastResizeWidth <= resizeAnimationBreakpoint && currentResizeWidth > resizeAnimationBreakpoint )
    {
      resizeAnimationTriggered = true;
    }
  }

  if ( resizeAnimationTriggered )
  {
    for ( var i = 0; i < resizeAnimationFrame; i++ ) // time of my own page resize animations
    {
      setTimeout(function() {
        calcParallax(false, resizeAnimationRateClamped);
      }, ( i + 1 ) * resizeAnimationRateClamped);
    }
  }

  lastResizeWidth = currentResizeWidth;
}

var smoothTimeout;

function smoothParallax( img, smooth ) {
  if ( smooth ) {
    img.style.transition = 'transform ' + smooth + 'ms cubic-bezier(0.65, 0.05, 0.68, 0.99)';

    smoothTimeout = setTimeout(function() {
      img.style.transition = '';
    }, smooth);
  }
  else if ( smoothTimeout )
  {
    clearTimeout(smoothTimeout);
    img.style.transition = '';
  }
}

How I Wrote The Code In 2015

Initial Concept

In order to acheive a parallax-like effect on images, we need to multiply the difference between the image's size, and it's container's by the amount it has passed by on the screen.


First Attempt

My first attempt at the effect implemented the use of a single element, to retain ease of replication, and using the background to both display and position the image. This first test wasn't optimized and was only meant to prove the implementation would work in the first place. Once the first implementation was successful, there were performance issues that needed to be resolved before it was complete.

The Initial Code From 2015

The Initial Code From 2015 ( preserved for historical sake, do not use )
HTML
<div class="parallax" data-parallax="150%"></div>
CSS
.parallax {
  background: url(<?php print $test_img_src ?>) no-repeat top left;
  background-position: 50% 50%;
  background-repeat: no-repeat;
  background-attachment: scroll;
  background-size: auto;
  border: 1px solid;
  border-color: rgba(0, 0, 0, 0.25) rgba(127, 127, 127, 0.1) rgba(255, 255, 255, 0.15);
  min-height: 50vh;
  max-height: 924px;
  border-radius: 10px;
}
JavaScript ( jQuery )
function scroll(){
  var scrollTop = jQuery(document).scrollTop(), windowHeight = jQuery(window).height();

  jQuery('.parallax').each(function() {
    var imgTopDist = jQuery(this).offset().top, wrapHeight = jQuery(this).height();
    var offset = Math.min(Math.max(((imgTopDist - scrollTop) + wrapHeight), 0), windowHeight + wrapHeight) / (windowHeight + wrapHeight);
    var img = jQuery(this),
      style = img.currentStyle || window.getComputedStyle(this, false),
      bgUrl = style.backgroundImage.slice(4, -1).replace(/"/g, "");
    var bg = new Image();

    bg.src = bgUrl;
    var bgWidth = bg.width, bgHeight = bg.height;
    var parallaxPerc = jQuery(this).attr("data-parallax");

    if(parallaxPerc) {
      oldBgHeight = bgHeight;
      bgHeight = wrapHeight*(1+parseFloat(parseInt(parallaxPerc) / 100));
      jQuery(this).css({ "background-size": bgWidth*(bgHeight / oldBgHeight)+'px ' + bgHeight+'px' });
    }

    var coords = '50% ' + (-1 * Math.abs(bgHeight-wrapHeight)*offset ) + 'px';
    jQuery(this).css({ "background-position": coords });
  });
}
jQuery(document).scroll(scroll);

Performance Issues

By trying to make things simple and designing the effect around one element using background images, many repaints were occuring. For every .parallax image on the page, a repaint was needed for every background position change, regardless if the position was actually different than before even when it was not the screen. This caused scroll performance issues, and was exagerated on weaker devices such as mobile phones and tablets.


Second Attempt: Basic Optimizations

To fix the issues with the first implmentation, the following steps were taken.

  • Store static variables.
  • Prevent off-screen updates to parallax elements.
  • Do not update the background image if positioning was same as before.

The Code From The Second Attempt In 2015

The Code From The Second Attempt In 2015 ( preserved for historical sake, do not use )
JavaScript ( jQuery )
function scroll(){
  var scrollTop = jQuery(document).scrollTop(), windowHeight = jQuery(window).height();

  jQuery('.parallax').each(function() {
    var imgTopDist = jQuery(this).offset().top, wrapHeight = jQuery(this).height(), scrollRange = windowHeight+wrapHeight;
    var imgRange = Math.min(Math.max(((imgTopDist - scrollTop) + wrapHeight), 0), scrollRange);

    if(imgRange >= 0 && imgRange <= scrollRange) {
      var offset = imgRange / scrollRange;

      var style = jQuery(this).currentStyle || window.getComputedStyle(this, false);
      var bg = new Image();
        bg.src = style.backgroundImage.slice(4, -1).replace(/"/g, "");
      var bgWidth = bg.width, bgHeight = bg.height;

      var parallaxPerc = jQuery(this).attr("data-parallax");
      if(parallaxPerc) {
        var newHeight = wrapHeight*(1+parseFloat(parseInt(parallaxPerc) / 100));
        if(bgHeight != newHeight) {
          var oldBgHeight = bgHeight;
          bgHeight = newHeight;
          jQuery(this).css({ "background-size": bgWidth*(bgHeight / oldBgHeight)+'px ' + bgHeight+'px' });
        }
      }

      jQuery(this).css({ "background-position": '50% ' + (-1 * Math.abs(bgHeight-wrapHeight)*offset ) + 'px' });
    }
  });
}
jQuery(document).scroll(scroll);

These changes acheived 16.3% faster frame times. The comparison of these attempts are shown below.

First Attempt

~37.75 ms

(33.3 - 42.2 ms) (~26.5 fps)

Frame Render Delays

Time (ms)Task
1.17 - 2.36Parallax Calculations
9.84 - 15.12Rasterized Re-Paints
74.39 - 114.29CPU time
430.23 - 451.32Rasterizer Image Decoding
Second Attempt

~31.65 ms

(23.3 - 40.0 ms) (~31.5 fps)

Frame Render Delays

Time (ms)Task
0.82 - 1.97Parallax Calculations
9.21 - 14.26Rasterized Re-Paints
31.66 - 54.24CPU time
430.23 - 451.32Rasterizer Image Decoding

This improvement to the code didn't help much with frame rates being as low as 26, 7, and even 5 frames per second. The image was being displayed as a background, and as such required expensive repaints upon every update. This could this be fixed a number of ways: apply a translate3d hack to the .parallax element in order to force all repaints of them to the faster GPU thread, use a full-sized 'img' element inside of a wrapper or use a canvas element. Since I did not currently like canvas elements, I did not attempt to use them.


Third Attempt: translate3d Hack

I tried using the translate3d hack in order to force all repaints of them to the faster GPU thread, as mentioned above, by adding transform: translate3d(0px,0px,0px); to my .parallax CSS class. The comparison of this attempt to the last is shown below.

Second Attempt

~31.65 ms

(23.3 - 40.0 ms) (~31.5 fps)

Frame Render Delays

Time (ms)Task
0.82 - 1.97Parallax Calculations
9.21 - 14.26Rasterized Re-Paints
31.66 - 54.24CPU time
430.23 - 451.32Rasterizer Image Decoding
Third Attempt

~16.7 ms

(14.2 - 43.1 ms) (~59.8 fps)

Frame Render Delays

Time (ms)Task
0.88 - 1.52Parallax Calculations
3.34 - 7.81Rasterized Re-Paints
29.55 - 45.91CPU time
430.23 - 451.32Rasterizer Image Decoding

Compared to the first attempt at ~37.75 ms, the parallax effect is now 126% (2.26x) faster, with frame rates as fast as 59-61fps! You would think this would be the end of my journey, but no. There were issues pertaining to Rasterizer image decodings taking 430.23 - 451.32 ms long, delaying the page scrolls by roughly half a second before each new parallax element came into view for the first time, as well as the occational drop to a 33.3-43.1 ms. Time for a revamp!


Final Attempt: Using the <img> Wrapped in a <div> Approach!

Many changes had been made in preporation for the fourth attempt:

  • The HTML structure was changed from a single div element to a div-wrapped image in order to avoid repaints.
  • The CSS was completly cleaned and rebuilt with mobile-rendering friendly attributes in mind (using transform3d() instead of top).
  • The jQuery / Javascript was also completly optimized in a push to minimize computations in every circumstance.
  • The demonstration image was reduced in size from 4.11MB to 1.03MB!

The Final Code From 2015

The Final Code From 2015 ( preserved for historical sake, you might be able to use this at your own risk )
HTML
<div class="parallax"><img src="../my-parallax-img.png"></div>
CSS
.parallax {
  border: 1px solid;
  border-color: rgba(0, 0, 0, 0.25) rgba(127, 127, 127, 0.1) rgba(255, 255, 255, 0.15);
  border-radius: 10px;
  height: 50vh; /* any fixed size works */
  overflow: hidden;
  position: relative;
  z-index: 1; /* forces wrapper border-radius to hide absolute img child */
}
.parallax img {
  position: absolute;
  left: 50%;
  top: 0px;
  min-width: initial;
  min-height: initial;
  max-width: initial;
  max-height: initial;
  width: auto;
  height: auto; /* overwritten on init */
  transform: translate3d(-50%, 0px, 0); /* overwritten on scroll, hardware accelerated */
}
JavaScript ( jQuery )
function calcParallax(recalc) {
  window.requestAnimationFrame(function() {
    var scrollTop = jQuery(document).scrollTop(), windowHeight = jQuery(window).height();

    jQuery('.parallax').each(function() {
      var wrapTopDist = jQuery(this).offset().top, wrapHeight = jQuery(this).height(), wrapRange = windowHeight+wrapHeight;
      var wrapPos = Math.max((wrapTopDist-scrollTop)+wrapHeight, 0);

      if(recalc || (wrapPos <= wrapRange && wrapPos >= 0)) {
        var wrap = jQuery(this), img = wrap.find('img'), amt = Math.min(wrapPos, wrapRange) / wrapRange;

        var imgHeight = img.height();
        if(recalc) {
          var newHeight = Math.round(wrapHeight*(1+(wrap.data("scale") ? parseFloat(wrap.data("scale")) : 1)));
          if(imgHeight != newHeight) {
            imgHeight = newHeight;
            img.css('height', imgHeight+'px');
          }
        }

        img.css('transform', 'translate3d(-50%, ' + -(Math.abs(imgHeight-wrapHeight)*amt).toFixed(Math.max(window.devicePixelRatio-1, 0))+'px, 0)');
      }
    });
  });
}

function checks() {
  calcParallax(true);
}
function scrollchecks() {
  calcParallax(false);
}

The results of moving from a background-image oriented design to a wrapper based one removed a lot of re-paint overhead as the browser was no longer needing to repaint an entire image on each scroll frame. Also, image decode times for the rasterizer disapeared entirely, meaning the reduction of roughly half a second's worth of delay on page load!

Third Attempt

~16.7 ms

(14.2 - 43.1 ms) (~59.8 fps)

Frame Render Delays

Time (ms)Task
0.88 - 1.52Parallax Calculations
3.34 - 7.81Rasterized Re-Paints
29.55 - 45.91CPU time
430.23 - 451.32Rasterizer Image Decoding
Fourth Attempt

~9.8 ms*

(10.6 - 23.1 ms) (~101.7 fps*)

Frame Render Delays

Time (ms)Task
0.76 - 0.92Parallax Calculations
0.5 - 5.35Rasterized Re-Paints
11.28 - 18.5CPU time
0Rasterizer Image Decoding

* Notice for Reported Speed and Frame Times

  • All reported frame times in milliseconds and frames per second were averaged values derived from using the runtime performance analyzer of the Google Chrome browser in 2015. Performance may vary with time and among other browsers.
  • The fourth attempt's millisecond speed and the frames per second are theoretical values derived from the ratio of the totaled min and max frame times of the third attempt and it's values applied using the min and max frame times of the fourth attempt. In real use testing the max frames per second were equivalent to the third attempt, most likely limited by the browser I was using to test this on. However, the fourth attempt had less lag spikes than the third attempt as the overhead for them was expanded thanks to the optimizations that were applied.

Special Thanks

This page is comprised of my own additions and either partially or heavily modified elements from the following source(s):

  • See image source information below.

Example Image Source(s)

ImageNameSource
landscape-mountains-nature-lake.jpegSource: https://static.pexels.com
Original Link ( no longer exists at this location ): https://static.pexels.com/photos/4062/landscape-mountains-nature-lake.jpeg