Show / Hide Parallax Images On Scroll

My current project requires some images to appear in the background of the top-right and bottom-left of a few specific content sections throughout a page. They need to be fixed in place (giving a parallax effect) to the viewport within the respective sections. They also need to gracefully clip behind the preceding and succeeding sections.

This is almost possible with CSS alone. You simply set the images to be position: fixed; but unfortunately any fixed position elements are always relative to the body element which means they’re always present on the screen no matter where the user scrolls. Of course, this shouldn’t matter much because we can simply hide them in the z-index stack but it does matter when you have multiple sets of parallax images to display throughout the page because they will all display at once. So we need some JavaScript to observe the DOM and show / hide them as needed.

I think this solution should work for most situations and can be tweaked to become more complex as needed. The only drawbacks I’m aware of at the moment is that you may have to monitor z-index stacking throughout the DOM, each section without parallax images preceding/succeeding a section that does have parallax images will need a background-color, and the parallax images will not clip behind preceding/succeeding a section’s margins. Other than that, it works nicely!

HTML

<main>
  <section class="section section--one">
    <h1>Scroll down!</h1>
    <p>wHoOoAaA!</p>
  </section>
  
  <section class="section section--two | background" data-scroll>
    <div class="background__content">
      <h1>Parallax images</h1>
      <p>Neat.</p>
    </div>

    <div class="background__container">
      <div class="background__item background__item--bottom-left">
        <img src="https://plus.unsplash.com/premium_vector-1744925928544-b62ea32f7fc4?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" alt="" width="250" height="375" loading="lazy" aria-hidden="true" draggable="false">
      </div>

      <div class="background__item background__item--top-right">
        <img src="https://plus.unsplash.com/premium_vector-1749297619697-feb572d1aa28?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mzd8fHN5bnRod2F2ZXxlbnwwfHwwfHx8MA%3D%3D" alt="" width="250" height="353" loading="lazy"aria-hidden="true" draggable="false">
      </div>
    </div>
  </section>
  
  <section class="section section--three">
    <h2>Supports multiple sets of images</h2>
    <p>Even though they are all fixed relatively to the body element. The "Scroll-Out" JS library will show/hide the appropriate images as needed.</p>
  </section>
  
  <section class="section section--four | background" data-scroll>
    <div class="background__content">
      <h1>Another set of parallax images</h1>
      <p>Just to prove it.</p>
    </div>

    <div class="background__container">
      <div class="background__item background__item--bottom-left">
        <img src="https://plus.unsplash.com/premium_vector-1740419064326-e751497b6080?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwcm9maWxlLXBhZ2V8MTI4fHx8ZW58MHx8fHx8" alt="" width="250" height="429" loading="lazy" aria-hidden="true" draggable="false">
      </div>

      <div class="background__item background__item--top-right">
        <img src="https://plus.unsplash.com/premium_vector-1731701606424-a4c80a591e19?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwcm9maWxlLXBhZ2V8MjM3fHx8ZW58MHx8fHx8" alt="" width="250" height="315" loading="lazy"aria-hidden="true" draggable="false">
      </div>
    </div>
  </section>
  
  <section class="section section--five">
    <h2>However...</h2>
    <p>Each section without parallax images (such as this one) needs a default background color and the parallax images may show behind this section's margins, so you may need to use padding instead :(. I'm sure there's some workarounds here but this suits my needs for the project at hand and I'm tired of this already.</p>
  </section>
</main>

CSS

body {
  font-family: sans-serif;
  margin: 0;
  
  h1,
  h2,
  p {
    line-height: 1.5;
    margin: 0;
    width: min(666px, 100%);
  }
}

.section {
  align-items: center;
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100vh;
  text-align: center;
}

.section--one {
  background-color: rebeccapurple;
  
  h1 {
    font-size: 5rem;
  }
  
  p {
    font-size: 1.5rem;
  }
  
  h1,
  h2,
  p {
    color: #fff;
  }
}

.section--two {
  
}

.section--three {
  background-color: firebrick;
  
  h1,
  h2,
  p {
    color: #fff;
  }
}

.section--four {
  background-color: limegreen;
}

// ***********************************
//  Background (Parallax)
// ***********************************
main > section,
main > div {
  background-color: white;
  position: relative;
  z-index: 2;
}

main > section,
main > div {
  &.background {
    z-index: 1;
    display: grid;
  }
}

.background__content {
  z-index: 2;
}

.background__container {
  z-index: 1;
}

.background__item {
  position: fixed;
  visibility: hidden;
  opacity: 0;
}

.background[data-scroll="in"] .background__item {
  visibility: visible;
  opacity: 1;
}

.background__item--bottom-left {
  inset: auto auto 0 0;
}

.background__item--top-right {
  inset: 0 0 auto auto;
}

JavaScript

Thanks to Christopher Wallis and his ScrollOut script!

// https://scroll-out.github.io/
// https://unpkg.com/scroll-out/dist/scroll-out.min.js
var ScrollOut=function(){"use strict"
function S(e,t,n){return e<t?t:n<e?n:e}function T(e){return+(0<e)-+(e<0)}var q,t={}
function n(e){return"-"+e[0].toLowerCase()}function d(e){return t[e]||(t[e]=e.replace(/([A-Z])/g,n))}function v(e,t){return e&&0!==e.length?e.nodeName?[e]:[].slice.call(e[0].nodeName?e:(t||document.documentElement).querySelectorAll(e)):[]}function h(e,t){for(var n in t)n.indexOf("_")&&e.setAttribute("data-"+d(n),t[n])}var z=[]
function e(){q=0,z.slice().forEach(function(e){return e()}),F()}function F(){!q&&z.length&&(q=requestAnimationFrame(e))}function N(e,t,n,r){return"function"==typeof e?e(t,n,r):e}function m(){}return function(L){var i,P,_,H,o=(L=L||{}).onChange||m,l=L.onHidden||m,c=L.onShown||m,s=L.onScroll||m,f=L.cssProps?(i=L.cssProps,function(e,t){for(var n in t)n.indexOf("_")&&(!0===i||i[n])&&e.style.setProperty("--"+d(n),(r=t[n],Math.round(1e4*r)/1e4))
var r}):m,e=L.scrollingElement,A=e?v(e)[0]:window,W=e?v(e)[0]:document.documentElement,x=!1,O={},y=[]
function t(){y=v(L.targets||"[data-scroll]",v(L.scope||W)[0]).map(function(e){return{element:e}})}function n(){var e=W.clientWidth,t=W.clientHeight,n=T(-P+(P=W.scrollLeft||window.pageXOffset)),r=T(-_+(_=W.scrollTop||window.pageYOffset)),i=W.scrollLeft/(W.scrollWidth-e||1),o=W.scrollTop/(W.scrollHeight-t||1)
x=x||O.scrollDirX!==n||O.scrollDirY!==r||O.scrollPercentX!==i||O.scrollPercentY!==o,O.scrollDirX=n,O.scrollDirY=r,O.scrollPercentX=i,O.scrollPercentY=o
for(var l,c=!1,s=0;s<y.length;s++){for(var f=y[s],u=f.element,a=u,d=0,v=0;d+=a.offsetLeft,v+=a.offsetTop,(a=a.offsetParent)&&a!==A;);var h=u.clientHeight||u.offsetHeight||0,m=u.clientWidth||u.offsetWidth||0,g=(S(d+m,P,P+e)-S(d,P,P+e))/m,p=(S(v+h,_,_+t)-S(v,_,_+t))/h,w=1===g?0:T(d-P),X=1===p?0:T(v-_),Y=S((P-(m/2+d-e/2))/(e/2),-1,1),b=S((_-(h/2+v-t/2))/(t/2),-1,1),D=void 0
D=L.offset?N(L.offset,u,f,W)>_?0:1:(N(L.threshold,u,f,W)||0)<g*p?1:0
var E=f.visible!==D;(f._changed||E||f.visibleX!==g||f.visibleY!==p||f.index!==s||f.elementHeight!==h||f.elementWidth!==m||f.offsetX!==d||f.offsetY!==v||f.intersectX!=f.intersectX||f.intersectY!=f.intersectY||f.viewportX!==Y||f.viewportY!==b)&&(c=!0,f._changed=!0,f._visibleChanged=E,f.visible=D,f.elementHeight=h,f.elementWidth=m,f.index=s,f.offsetX=d,f.offsetY=v,f.visibleX=g,f.visibleY=p,f.intersectX=w,f.intersectY=X,f.viewportX=Y,f.viewportY=b,f.visible=D)}H||!x&&!c||(l=C,z.push(l),F(),H=function(){!(z=z.filter(function(e){return e!==l})).length&&q&&(cancelAnimationFrame(q),q=0)})}function C(){u(),x&&(x=!1,h(W,{scrollDirX:O.scrollDirX,scrollDirY:O.scrollDirY}),f(W,O),s(W,O,y))
for(var e=y.length-1;-1<e;e--){var t=y[e],n=t.element,r=t.visible,i=n.hasAttribute("scrollout-once")||!1
t._changed&&(t._changed=!1,f(n,t)),t._visibleChanged&&(h(n,{scroll:r?"in":"out"}),o(n,t,W),(r?c:l)(n,t,W)),r&&(L.once||i)&&y.splice(e,1)}}function u(){H&&(H(),H=void 0)}t(),n(),C()
var r=0,a=function(){r=r||setTimeout(function(){r=0,n()},0)}
return window.addEventListener("resize",a),A.addEventListener("scroll",a),{index:t,update:n,teardown:function(){u(),window.removeEventListener("resize",a),A.removeEventListener("scroll",a)}}}}()

// Target the element
ScrollOut({
    targets: '.background'
});