Four Kitchens: Creating depth and motion: A step-by-step guide to parallax
Frontend Engineer
Mari is a high-achieving Drupal frontend developer who has shown great proficiency in tackling complex frontend problems.
January 1, 1970
Crafting a visually engaging website isn’t just about eye-catching colors and typography — today it’s also about creating immersive experiences that captivate users as they scroll. One of the most compelling ways to achieve this is by using a parallax effect, where elements move at different speeds to create a sense of depth and motion.
With a thoughtful approach and some JavaScript, you can seamlessly add this effect to your site, enhancing storytelling and making your pages more dynamic.
This post will guide you through the process of integrating a custom parallax effect into your site. Whether building a feature-rich landing page or enhancing storytelling elements, this technique can bring your site to life. Let’s begin.
Building our component
Our example site was built in Drupal, but the overall concept would be the same in any CMS. First, we will need to build a component that has all the necessary fields that we want to display in our parallax. In this example, we will use Paragraph types and have two kinds of slides: one with an image and another without an image.
Parallax image slide
This slide will let us add an image, a title, and the caption or information we want to tell about that specific slide, the alignment of the information (left, center, or right), and an option if we want to hide the credit of the image or show it.

Parallax blank slide
This slide is similar to the previous one, but there are key differences. This one won’t include an image and anything else related to the image, and we allow a lot more text formatting on blank slides. This means that we can have the text on a blank slide take up much more of the available space without worrying about color contrast issues with advanced text formatting.
Once both paragraphs have been created, let’s create a ‘Parallax Slideshow’ paragraph that will only have a field that references the previous paragraphs created.
Connecting our component to the custom theme
Once our component is ready, the next step is to integrate it into our custom theme. In this example, we’re using Emulsify, our design system, as our custom theme, to ensure a consistent and modular approach to theming.
First, we will have our paragraph--parallax-slideshow.html.twig
that will include a parallax-slideshow.twig
, which has a JavaScript library called parallax-slideshow that is in charge of all logic to make our parallax effect work, and also some required styles.
Here’s what our parallax-slideshow.twig looks like. Notice the empty <div class=””parallax-slideshow__image-wrapper””></div>. This is where the slide images will be rendered and where the fade-in and fade-out effects between images will occur.
{% set classes = [ paragraph.bundle|clean_class, "parallax-slideshow", ] %} <div{{ attributes.addClass(classes) }} data-id="{{ slideshow_id }}"> <div class="parallax-slideshow__wrapper"> <div class="parallax-slideshow__image-wrapper"></div> {{ slides }} </div> </div>Then, we will have a paragraph--parallax-image-slide.html.twig
and a paragraph--parallax-blank-slide.html.twig
. Both files include a parallax-slide.twig
, which is a molecule in the design system that organizes the content of each slide and adds all the needed styles. They are almost identical — the only difference being that the blank-slide will not pass the slide image to the parallax-slide.twig
file.
Here’s what our parallax-slide.twig
looks like:
Preloading parallax slideshow data
To prevent a visible delay between slides, the component needs to preload the first two images on page load. As the user begins scrolling, additional images are loaded dynamically in the background. This ensures a seamless transition between slides without noticeable lag and enhances the overall user experience.
We need to pass structured data from the backend to JavaScript. Below is a function that loads the data and attaches it to drupalSettings
for use in a theme.
Once the data is attached to drupalSettings
in our JavaScript file, we can access parallaxSlideshowData
to dynamically load images and control the parallax effect.
JavaScript implementation of the parallax slideshow
Below is a breakdown of how the JavaScript file works to bring the parallax slideshow to life.
Drupal.behaviors.parallaxSlideshow = { attach: function (context) { const parallaxSlideshowData = drupalSettings.yourTheme.parallaxSlideshowData; if (!parallaxSlideshowData) return; const slideshows = once('parallax-slideshow', '.parallax-slideshow', context); slideshows.forEach((slideshow) => { const loadedSlideIds = new Set(); const loadedImages = new Set(); initializeParallaxSlideshow(slideshow, parallaxSlideshowData, loadedSlideIds, loadedImages); }); }, };Let’s start by retrieving the slideshow data from drupalSettings
and ensuring the script only runs once per slideshow element. The function initializeParallaxSlideshow
is responsible for setting up and managing the parallax slideshow experience by initializing each slideshow. By tracking which slides have been loaded, we prevent redundant loading:
Then, it calls a preloadSlides
function, which likely preloads images or other resources for the first two slides to prevent a visible delay between slides.
Next, it calls a createImageDiv
helper function that is responsible for creating and managing an image element within the parallax slideshow.
The reason why we check if there is a firstImage
is that we want the initial slide to fade in from black when it’s fully loaded. Once the image loads, it finds the overlay and the slideshow wrapper, fades out the overlay, removes the overlay, and re-enables scrolling.
Let’s go back to the initializeParallaxSlideshow
. After the preloadSlides
function there’s a scroll
event listener for the parallax effect that listens for scroll events to update the slideshow’s image position dynamically.
The idea is to let the image wrapper take the whole height of the viewport, but since there can be components before or after the parallax slideshow, at some point it is necessary to change the position of the image wrapper, to let the user scroll and interact with other components.
window.addEventListener('scroll', () => { const windowHeight = window.innerHeight; const top = slideshow.getBoundingClientRect().top; const bottom = slideshow.getBoundingClientRect().bottom; const slideshowImageWrapper = slideshow.querySelector( '.parallax-slideshow__image-wrapper', ); if (top < 0 && bottom > windowHeight) { slideshowImageWrapper.style.position = 'fixed'; slideshowImageWrapper.style.top = 0; } else { slideshowImageWrapper.style.position = 'absolute'; if (windowHeight > bottom) { slideshowImageWrapper.style.top = 'unset'; slideshowImageWrapper.style.bottom = 0; } if (windowHeight < top) { slideshowImageWrapper.style.top = 0; slideshowImageWrapper.style.bottom = 'unset'; } } });The following logic is to set a scroll hijacking if the parallax slideshow is the first component of the page and if the first slide is an image.
// Check if slideshow is within a parent of .content-top const isContentTopParent = slideshow.closest('.content-top') !== null; // Get the first slide and check if it has the class `parallax-slide--parallax-image-slide` const firstSlide = slideshow.querySelector('.parallax-slide'); const isFirstSlideParallaxImageSlide = firstSlide && firstSlide.classList.contains('parallax-slide--parallax-image-slide'); // Lock scroll if .content-top is present and the first slide is of type image if (isContentTopParent && isFirstSlideParallaxImageSlide) { const overlay = document.createElement('div'); overlay.className = 'parallax-slideshow__overlay'; slideshow .querySelector('.parallax-slideshow__wrapper') .appendChild(overlay); document.body.style.overflow = 'hidden'; }Then, there’s a piece of code that iterates through all slides in the slideshow and calls the initializeSlideObserver()
function on each slide.
Now let’s take a look at the initializeSlideObserver()
function — the one that is responsible for setting up an Intersection Observer to track when a slide enters the viewport and dynamically updates the slideshow’s displayed image accordingly. It ensures that the slideshow loads the next image only when needed, preventing unnecessary rendering and improving performance.
Last but not least, there’s the loadNextSlide
function that is responsible for preloading the next slide’s image to ensure a smooth transition when the user scrolls. This prevents unnecessary reloading of already loaded images. This function is very similar to the preloadSlides
function.
With these functions in place — handling image creation, slide observation, and preloading — you now have a dynamic and efficient parallax slideshow that seamlessly transitions between images as users scroll. By leveraging the Intersection Observer API, preloading logic, and smooth fade effects, the slideshow ensures a visually engaging experience without unnecessary performance overhead.
Once you’ve added the necessary styles to control positioning, animations, and transitions, your parallax slideshow should be fully functional across your site. This approach not only enhances the storytelling aspect of your content, but also keeps interactions smooth and lightweight.
Now, all that’s left is to fine-tune the visuals to match your design, and you’re set to create an immersive scrolling experience!
The post Creating depth and motion: A step-by-step guide to parallax appeared first on Four Kitchens.