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

{%
set classes = [
‘parallax-slide’,
slide_caption_alignment ? ‘parallax-slide–caption-‘ ~ slide_caption_alignment|lower : ”,
slide_type ? ‘parallax-slide–‘ ~ slide_type|replace({‘_’: ‘-‘}) : ”,
]
%}

<div {{ attributes.addClass(classes) }} slide-data-id=”{{ slide_id }}”>
<div class=”parallax-slide__info-wrapper”>
<div class=”parallax-slide__info-inner-wrapper full-width”>
{% if slide_title %}
<div class=”parallax-slide__title-wrapper”>
<h1 class=”parallax-slide__title”>{{ slide_title }}</h1>
</div>
{% endif %}
{% if slide_caption %}
<div class=”parallax-slide__caption”>{{ slide_caption }}</div>
{% endif %}
</div>
</div>
</div>

Preloading parallax slideshow data

function createImageDiv(slideID, slideImage, slideshow, loadedImages, firstImage = false) {
const imgDiv = document.createElement(‘div’);
imgDiv.className = ‘parallax-slideshow__image’;
imgDiv.innerHTML = slideImage;

if (firstImage) {
const image = imgDiv.querySelector(‘img’);
image.addEventListener(‘load’, () => {
const slideshowOverlay = slideshow.querySelector(
‘.parallax-slideshow__overlay’,
);
const slideshowWrapper = slideshow.querySelector(
‘.parallax-slideshow__wrapper’,
);
if (slideshowOverlay) {
slideshowOverlay.classList.add(‘fade-out’);
setTimeout(() => {
document.body.style.overflow = ”;
slideshowWrapper.removeChild(slideshowOverlay);
}, 1000);
}
});
}

// Add a custom attribute for the slide ID
imgDiv.setAttribute(‘data-slide-image-id’, slideID);

loadedImages.add({
id: slideID,
image: imgDiv,
});
}

Frontend Engineer
slide items

Connecting our component to the custom theme

function your_theme_preprocess_paragraph_parallax_slideshow(&$variables) {
$paragraph = $variables[‘paragraph’];
$pid = $paragraph->id();
$lazy_load_data[$pid] = [];
if ($paragraph->hasField(‘field_slide_items’)) {
$slide_items_ref = $paragraph->get(‘field_slide_items’);
$slide_items = $slide_items_ref->referencedEntities();

foreach ($slide_items as $slide_id => $slide) {

// Initial setup of array.
$lazy_load_data[$pid][$slide_id] = [
‘id’ => NULL,
‘image’ => NULL,
];

// ID.
if ($slide->hasField(‘id’) && !$slide->get(‘id’)->isEmpty()) {
$lazy_load_data[$pid][$slide_id][‘id’] = $slide->get(‘id’)->first()->getValue();
}

// Responsive image markup.
if ($slide->hasField(‘field_image’) && !$slide->get(‘field_image’)->isEmpty()) {
$lazy_load_data[$pid][$slide_id][‘image’] = _your_theme_get_rendered_slide_image($slide);
}
}
}

// Attach to JS JSON object to read in theme.
$variables[‘#attached’][‘drupalSettings’][‘yourTheme’][‘parallaxSlideshowData’] = $lazy_load_data;
$variables[‘#attached’][‘library’][] = ‘your_theme/parallax-slideshow’;
}

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.

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’;
}
}
});

With a thoughtful approach and some JavaScript, you can seamlessly add this effect to your site, enhancing storytelling and making your pages more dynamic.
When you subscribe to our newsletter!
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.

JavaScript implementation of the parallax slideshow

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.
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.
Mari Núñez
function loadNextSlide(slideshowData, currentIndex, loadedSlideIds, loadedImages) {
if (currentIndex + 1 < slideshowData.length) {
const nextSlideData = slideshowData[currentIndex + 1];
// Check if the slide has already been added
if (loadedSlideIds.has(nextSlideData.id)) return;

// Mark the slide as loaded
loadedSlideIds.add(nextSlideData.id);

if (nextSlideData.image !== null) {
createImageDiv(nextSlideData.id, nextSlideData.image, null, loadedImages);
}
}
}

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);
});
},
};

// 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’;
}

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.
const slides = slideshow.querySelectorAll(‘.parallax-slide’);
slides.forEach((slide, index) => {
const infoInnerWrapper = slide.querySelector(
‘.parallax-slide__info-inner-wrapper’,
);
initializeSlideObserver(slideshow, infoInnerWrapper, slide, slideshowData, loadedSlideIds, loadedImages);
// Add classes if first image is an slide
if (index === 0) {
slide.classList.add(‘initial-slide’);
if (isFirstSlideParallaxImageSlide) {
slide.classList.add(‘initial-slide-image’);
}
}
});

{%
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>

{% include “@molecules/parallax-slide/parallax-slide.twig” with {
‘slide_id’: paragraph.id.0.value,
‘slide_img’: content.field_image|render,
‘slide_title’: paragraph.field_component_title.0.value,
‘slide_caption’: content.field_caption|render,
‘slide_caption_alignment’: paragraph.field_caption_alignment.0.value,
‘slide_hide_credit’: paragraph.field_hide_credit.0.value,
‘slide_type’: paragraph.type.0.value.target_id,
} %}

May 30, 2025
// Initialize Intersection Observer for Slides
function initializeSlideObserver(slideshow, infoInnerWrapper, slide, slideshowData, loadedSlideIds, loadedImages) {
// Watches when infoInnerWrapper enters or exits the viewport,
// and triggers a callback whenever visibility changes
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const slideshowWrapper = slideshow.querySelector(
‘.parallax-slideshow__wrapper’,
);
const slideshowImageWrapper = slideshowWrapper.querySelector(
‘.parallax-slideshow__image-wrapper’,
);
const slideID = slide.getAttribute(‘slide-data-id’);
const slideImage = Array.from(loadedImages).find(
(loadedImage) => loadedImage.id === slideID,
);

const { isIntersecting } = entry;

// Checks if the slide currently intersects the root
if (isIntersecting) {
const parent = slide.parentNode;
const slides = Array.from(
parent.querySelectorAll(‘.parallax-slide’),
);
const index = slides.indexOf(slide);
if (index !== 0) {
// If not first slide, call the function to preload the next slide in advance.
loadNextSlide(slideshowData, index, loadedSlideIds, loadedImages);
}

// Check if there is an existing image
const previousImage = slideshowImageWrapper.querySelector(
‘.parallax-slideshow__image’,
);

if (slideImage) {
slideImage.image.classList.add(‘fade-in’);
slideshowImageWrapper.appendChild(slideImage.image);

// If an existing image is found, remove fade-in class and remove it after a delay
if (previousImage) {
const previosImageID = previousImage.getAttribute(
‘data-slide-image-id’,
);

if (previosImageID !== slideID) {
setTimeout(() => {
previousImage.classList.add(‘fade-out’); // Add fade-out class
previousImage.classList.remove(‘fade-in’); // Remove fade-in class
previousImage.classList.remove(‘fade-out’); // Remove fade-out class
slideshowImageWrapper.removeChild(previousImage);
}, 500);
}
}
} else {
if (previousImage) {
const previosImageID = previousImage.getAttribute(
‘data-slide-image-id’,
);

if (previosImageID !== slideID) {
previousImage.classList.add(‘fade-out’); // Add fade-out class
setTimeout(() => {
previousImage.classList.remove(‘fade-out’);
slideshowImageWrapper.removeChild(previousImage);
}, 500);
}
}
}
}
});
},
{
// The callback triggers when at least 5% of infoInnerWrapper is visible.
threshold: 0.05,
},
);

observer.observe(infoInnerWrapper);
}

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.
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.

Similar Posts