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.
- 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.
- Convert the scripts from jQuery to JavaScript, as I no longer like to include large libraries if I do not have to.
- 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 thecalcParallax
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.
~37.75 ms
(33.3 - 42.2 ms) (~26.5 fps)
Time (ms) | Task |
---|---|
1.17 - 2.36 | Parallax Calculations |
9.84 - 15.12 | Rasterized Re-Paints |
74.39 - 114.29 | CPU time |
430.23 - 451.32 | Rasterizer Image Decoding |
~31.65 ms
(23.3 - 40.0 ms) (~31.5 fps)
Time (ms) | Task |
---|---|
0.82 - 1.97 | Parallax Calculations |
9.21 - 14.26 | Rasterized Re-Paints |
31.66 - 54.24 | CPU time |
430.23 - 451.32 | Rasterizer 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.
~31.65 ms
(23.3 - 40.0 ms) (~31.5 fps)
Time (ms) | Task |
---|---|
0.82 - 1.97 | Parallax Calculations |
9.21 - 14.26 | Rasterized Re-Paints |
31.66 - 54.24 | CPU time |
430.23 - 451.32 | Rasterizer Image Decoding |
~16.7 ms
(14.2 - 43.1 ms) (~59.8 fps)
Time (ms) | Task |
---|---|
0.88 - 1.52 | Parallax Calculations |
3.34 - 7.81 | Rasterized Re-Paints |
29.55 - 45.91 | CPU time |
430.23 - 451.32 | Rasterizer 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 adiv
-wrapped image in order to avoid repaints. - The CSS was completly cleaned and rebuilt with mobile-rendering friendly attributes in mind (using
transform3d()
instead oftop
). - 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!
~16.7 ms
(14.2 - 43.1 ms) (~59.8 fps)
Time (ms) | Task |
---|---|
0.88 - 1.52 | Parallax Calculations |
3.34 - 7.81 | Rasterized Re-Paints |
29.55 - 45.91 | CPU time |
430.23 - 451.32 | Rasterizer Image Decoding |
~9.8 ms*
(10.6 - 23.1 ms) (~101.7 fps*)
Time (ms) | Task |
---|---|
0.76 - 0.92 | Parallax Calculations |
0.5 - 5.35 | Rasterized Re-Paints |
11.28 - 18.5 | CPU time |
0 | Rasterizer 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):
Example Image Source(s)
Image | Name | Source |
---|---|---|
landscape-mountains-nature-lake.jpeg | Source: https://static.pexels.com Original Link ( no longer exists at this location ): https://static.pexels.com/photos/4062/landscape-mountains-nature-lake.jpeg |