#css#design#parallax

Overcoming the challenges associated with Parallax Scrolling

Syed Sibtain and Jawakar Durai's avatar

Syed Sibtain and Jawakar Durai

Browsers and web development techniques are evolving. This allows us to display stunning effects on our websites. One of these has been the most popular web design trend in recent years: The Parallax Scrolling Effect.

Parallax scrolling is a visual effect that creates the illusion of depth by having the background and foreground elements (i.e., two or more layers) on a Web page scroll at different speeds.

As users scroll down the website, this 3D effect adds depth and makes the browsing experience more engaging and immersive.

What problem are we trying to resolve here?

"The art challenges the technology, and the technology inspires the art." - John Lasseter -

Although parallax scrolling techniques increase the aesthetic interest of a website, they frequently lead to usability problems, such as delayed loading or difficult reading of content. It’s simply because parallax scrolling uses a significant amount of movement, which can cause performance issues and may not work as intended in all browsers.

The websites of some of the largest companies in the world today use parallax scrolling to enhance the user experience. However, putting this effect into action can be complicated, so it should only be used occasionally.

In a nutshell, parallax scrolling is typically handled via JavaScript, which leads to sloppy implementations that frequently trigger unwanted reflows. This includes modifying the DOM directly in the handler by listening to the scroll event. All of this should run along with the browser’s rendering workflow, which causes skipped frames and stuttering. And this blog will discuss all of the various methods we may employ to achieve parallax as well as the limitations that each method presents.

The Design

To help us grasp things better, let's pick a simple layout. The idea is to make a conventional carousel and below the carousel we will have two different sets of images with parallax scrolling effect.

Parallax_image

To begin, there are several ways to accomplish this, including using JavaScript, CSS, or a scroll library such as Locomotive-Scroll. And we can also learn why some methods are unsuitable.

Using onscroll

The simplest and most obvious method to achieve it would be to use on-scroll events and build a component that modifies the value of the image's translate-y CSS property when an event occurs. As a result, it will appear that the image only scrolls in the opposite direction.

When using React JS, our first instinct is to separate the component and reuse it. So we'll call it <ParallaxImage /> and we will design the component to accept an image.

We are using framer-motion to control the animations and tailwind CSS for the styling.

export const ParallaxImage = ({ image, children }) => {
  const [elementTop, setElementTop] = useState(0);
  const [clientHeight, setClientHeight] = useState(0);
  const container = useRef(null);
 
  const height = window.innerWidth / 2;
  const initial = elementTop - clientHeight;
  const final = elementTop + height;
  const { scrollY } = useViewportScroll();
  const y = useTransform(scrollY, [initial, final], [0, height]);
 
  useLayoutEffect(() => {
    const element = container.current;
 
    setElementTop(element.getBoundingClientRect().top + window.scrollY);
    setClientHeight(window.innerHeight);
  }, []);
 
  return (
    <div
      className="w-full m-auto relative overflow-hidden pt-[100%]"
      ref={container}
    >
      <motion.div
        className="absolute -top-full w-full h-[200%]"
        style={{
          y,
        }}
      >
        <img src={image} className="h-full" />
      </motion.div>
    </div>
  );
};

We won't get into specifics on how it's done, but here's the quick rundown:

-> We are using useTransform from framer-motion to control the animation where the transform-y value should transverse from 0 to the full height of the image based on when the animation should start and when it should end. (i.e., from initial to final). Whenever the scrollY value changes, the transform will update.

-> To get the viewport's scroll position, we are utilising the function useViewportScroll.

->  The -top-full h-[200%] moves 50% of the images to the top and keeps only 50% visible. Because of this, we can increase the translate-y to create the effect of scrolling in the opposite direction.

According to our requirements, our layout requires four images similar to this. The component can therefore be used as follows:

<div className="grid grid-cols-6 lg:grid-cols-12">
  <ParallaxImage image={image1} />
  <ParallaxImage image={image2} />
  <ParallaxImage image={image3} />
  <ParallaxImage image={image4} />
</div>

This method has a flaw because we alter the translate-y property of all 4 components on each scroll event. As a result, when we updated the images' translate-y, the visuals have a really poor jittery effect.

And for this, we can try using the will-change CSS attribute as it instructs browsers on how to interpret a changing element better.

.element {
  will-change: transform;
}

However it didn't really make a difference. If the update area is sufficiently large, the browser may occasionally skip making any changes to the will-change property.

Less onscroll

As much as browsers adore optimization, so do we as developers. Since in the first approach, each component had its own image and on-scroll events attached, when it was used many times, all 4 components transformed the picture in accordance with the transform attribute.

As a result, we could switch the on-scroll event to the parent element so that we could update all 4 image transform-X CSS values.

The downside of this is that it will begin updating the image even before it enters the viewport.

export const ParallaxImage = ({ image, y }) => {
  return (
    <div
      className="w-full m-auto relative overflow-hidden pt-[100%]"
      ref={container}
    >
      <motion.div
        className="absolute -top-full w-full h-[200%]"
        style={{
          y,
        }}
      >
        <img src={image} className="h-full" />
      </motion.div>
    </div>
  );
};
 
const Parent = () => {
  const height = window.innerWidth / 2;
  const { scrollY, scrollYProgress } = useViewportScroll();
  const y = useTransform(scrollYProgress, [0, 1], [0, height]);
 
  return (
    <div className="grid grid-cols-6 lg:grid-cols-12">
      <ParallaxImage image={image1} y={y} />
      <ParallaxImage image={image2} y={y} />
      <ParallaxImage image={image3} y={y} />
      <ParallaxImage image={image4} y={y} />
    </div>
  );
};

After changing our approach, the jittery effect has diminished from being quite noticeable to only occurring sometimes, usually as we begin scrolling and occasionally when scrolling. We observed now that the jittery effect reveals itself only during the image transitions in the top carousel. Recall that the layout at the top features a carousel.

We deduced that the auto-changing carousel makes use of the AnimatePresense component of the framer motion. On entering and exiting of images, the image is transitioned using opacity. The opacity changes from 0 to 1 and vice versa for 1 second. It's not a CSS transition, but framer motion changing the opacity from 0 to 0.2, 0.4, 0.5,...1 for a second at 60 frames per second, updating the element every 16ms.

The implementation of the carousel was moved to CSS using the opacity and transition properties, which has reduced the jittery impact.

The browser will paint with each update, thus updating opacity repeatedly is not a good idea. For more information on reflow and repaint, see here.

Breaking things down

It was now time to determine what was causing this problem and whether it was even possible to achieve something like this effect without any glitchy effects. Sometimes, determining the root cause is more important than determining the solution.

After investigating the performance tab (Chrome) and the timelines tab (Safari), as these browsers are the source of the majority of the issues, we discovered that the scroll event was not being called synchronously. Despite the fact that we scrolled at-least 500 pixels, the on-scroll handler was only called twice. Once, when we started scrolling and again when we stopped scrolling, the in-between events were not fired, which certainly contributed to the jittery experience.

We performed a proof of concept in Webflow for the same layout to know how they do it and discovered that it handled the scroll event flawlessly, We discovered some outdated forums after searching through source code and some webflow forums. The links are provided below.

  • https://discourse.webflow.com/t/tram-more-words-to-finish-this-title/1502
  • https://discourse.webflow.com/t/library-used-for-webflow-interactions/9584

We came to the conclusion that they use their own interaction library and Tram JS. Furthermore, we cannot use it because the latest upgrade was six years ago.

CSS is always better than JS

At this point, it is clear that the browser is upgrading significant elements, in our example, four images—for 1920 x 1080 display. If an image is of 920 \* 960 = 9,21,000 pixels, then every time a scroll event occurs, 36,84,000 pixels are updated. It seems big even if we're utilising the GPU with the transform property.  When examining the output from the performance tab, it becomes clear that the only problem is with the way the browser handles compositing layers, which indeed is the cause of the bug.

It was now crucial to separate JavaScript and use CSS to achieve this tricky effect. Keith Clark has done significant work in the area of using CSS 3D to achieve parallax motion. CSS should completely solve our problem because the browser does not need to calculate too much on the scroll event and this is entirely dependent on the CSS render engine.

"The most important question now is how to accomplish this without utilising any JS." Let's understand with the help of the example.

export const ParallaxImage = ({ image }) => {
  return (
    <div className="parallax-image">
      <img src={image} />
    </div>
  );
};
 
const Parent = () => {
  return (
    <div className="parent-container">
      <ParallaxImage image={image1} />
      <ParallaxImage image={image2} />
      <ParallaxImage image={image3} />
      <ParallaxImage image={image4} />
    </div>
  );
};

And the CSS for the component will be as below:

.parent-container {
  width: 100%;
  height: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
  perspective: 1px;
  perspective-origin: 0 0;
}
 
.parallax-image {
  transform-origin: 0 0;
  transform: translateZ(-1px) scale(3);
}

If we look at the above code, here is what we have done. First we set up the parent component container and specified with overflow-y: scroll (and probably overflow-x: hidden). Also we added perspective-origin and perspective value to the same.

And finally, we will translate the child component parallax-image in Z-axis and scale them back up to create the parallax effect.

Now let's understand why it works?

When we talk about scrolling, we are only referring to the transform that involves the movement of various webpage layers. Additionally, scrolling typically occurs in a ratio of 1:1, meaning that if we move the parent-container down by 100 pixels, all of its ParallaxImage components will also move down by the same distance which is 100 pixels.

Because we now have the perspective value, the formulae that support the scroll transform have been changed, and a 100 pixels scroll may now only move the children by 50 pixels, depending on the perspective and translateZ values we have chosen. If we leave the translateZ value at 0, it will scroll at the same rate as before, which is 1:1. However a child pushed in Z away from the perspective-origin, on the other hand, will be scrolled at a different rate.

The result is a stunning parallax effect.

We truly believed that this was our last chance. Unfortunately, it did not work out for us. And the big question, why?

Parallax_image

To demonstrate it furthermore, let's take Keith's example here and we observed that their demos rely heavily on scaling the 3D affecting image and moving it close to the camera (which is display in our case) to achieve the parallax effect, which causes the image to overflow.

In the above demo, Keith uses another element at the bottom to hide the overflow of the scaling. But in our case, we don't have any elements at the bottom to hide the overflow. The element at the bottom also should do the parallexing. And we can't use overflow: hidden here. There are some solutions listed here too: https://css-tricks.com/things-watch-working-css-3d/#aa-1-overflow and https://stackoverflow.com/a/69664046, but they're very limited to certain scenarios.

If you don't need something like our layout, I recommend using CSS instead. It is extremely simple and straightforward to implement. It's also very efficient and performant.

It surprises me that Firefox manages to make the overflow: hidden work. However, Chrome and Safari continued to have problems. The fact that each browser uses a different engine. The rendering engine of the browser includes the CSS engine (Eg, Blink - Chrome). The HTML and CSS files for the website are used by the rendering engine to create the pixels that appear on the screen. These engines read and interpret the source code. A website will be interpreted and displayed differently by each browser's engine. That implies that the exact same website can appear and behave differently among browsers.

Finally

We looked for a reliable alternative to scroll jacking. It appears that sometimes it is preferable to use it with the caution of not using it excessively. We're still looking for a scroll jacking-free solution. Please let us know if you have any suggestions. And we'll update this if we come across a solution.

Some amazing examples of Parallax

  1. [Apple] (https://www.apple.com/ipad-pro/)
  2. [Restaurant Le Duc] (http://restaurantleduc.com/)
  3. [Fluttuo] (http://www.fluttuo.com/#home)

References