An Overlapping "Parallax" Effect on Next.js with Preserved Server-Side Rendering

An Overlapping "Parallax" Effect on Next.js with Preserved Server-Side Rendering

Let's make it clear right from the start - this is NOT parallax, though sometimes customers might call it that way...

Recently, I took part in a project where the customers kept insisting they needed whole sections of their website to move with parallax on scroll, when finally it appeared they had meant a completely different thing.


Genuine parallax implies the background of a container and its content move with different speed, which creates an eye-catching optical illusion. When a few containers in a row are given this effect, they seem to start overlapping each other on scroll while they actually do NOT - they still go one after another and it is the speed discrepancy between the background and content inside each container that makes us believe the overlapping takes place. Here are some legit examples of parallax.

Genuine parallax 1

Genuine parallax 2

Genuine parallax 3

There are a few ways and lots of tutorials on how to achieve this effect. However, despite attractive looks, parallax can sometimes be rather inconvenient and even inapplicable.

For instance, when your website has not just one or two but several sections (containers) each having lots of important text content moving with parallax - at a certain point the user might not be able to see a great part of the content, because it will go away faster than it appears in front of the user's eyes.

Problematic parallax 1

Problematic parallax 2

Problematic parallax 3

Or, when your sections do not actually have any background image at all but rather a certain background color - creating attractive parallax can be quite challenging.


So, in these cases, we might want to apply something else, something as presentable but more practical - something like the effect shown in the following video.

Here, the sections ACTUALLY overlap each other, fold in "a pile" on scrolling down and unfold one by one on scrolling backwards.

There is a range of examples showing how we can make this effect with pure CSS (position: sticky) or with both CSS and some amount of JavaScript, including my own example - not very elegant though.

Overlapping example 1

Overlapping example 2

Overlapping example 3

My overlapping example


However, in some circumstances these options may cease to be helpful as well. For example, working on the project I started this article with, I ran into two problems:

Problem 1. All of the aforementioned options imply that each section's height should not exceed the viewport height. Otherwise, if a section which does not fit into the viewport stops moving upon reaching the top of the viewport - the user will not see a part of the content at the bottom of the section, and this is bad.

A section that is higher than the viewport stopped moving when it reached the header - a great part of the content at the bottom became unreacheble to the user

A possible way to solve this problem is by watching the offsetHeight of each section and comparing it with the innerHeight of the viewport:

  • If section.offsetHeight <= window.innerHeight - the section must stop when its top reaches the top of the viewport.
  • If section.offsetHeight > window.innerHeight - the section must stop when its bottom reaches the bottom of the viewport, while the top will be scrolled upwards and get beyond the top of the viewport.

Problem 2. In terms of React the above solution will require that we wrap all our sections in a wrapper component with a controlled state, where the offsetHeight of the current section will be watched and each time a decision will be made on when the current section must stop moving.

When it comes to Next.js, however, another matter of concern will inevitably arise here - obviously, our controlled-state wrapper must be a client-side component, but it would be quite nice if we preserved the sections with all their content as server-side components, otherwise the whole thing with Next.js would turn out to be of no good use to us, wouldn't it?

Luckily, Next.js allows us to preserve server-side rendering of the sections, if we insert them inside the client-side wrapper as its children, i.e. via children prop of the wrapper component.

Next.js documentation

<Wrapper> // client-side
     <Section /> // server-side
     <Section /> // server-side
     <Section /> // server-side
</ Wrapper>        

In my search for the best solution to the whole problem, which would support such configuration and fit into Next.js logic, after trying various libraries and custom options, I stopped at gsap.

gsap

Here you can see an example from the creators of gsap showing how we can make the overlapping effect using this library.

Overlapping effect from gsap

One good reason to pick this option is that unlike the rest of the examples mentioned previously it takes into account the fact that the sections may actually be of various height, and some of them may even be higher than the viewport!

Another advantage is that not all libraries (like react-spring or react-scroll-parallax) support this configuration - server-side components inside a client-side one - but gsap does.

On the other hand, there is still no explanation how to apply it to a React app not to say of Next.js, whereas a bunch of pitfalls related to such application does exist.

Therefore, I came up with my own more convenient solution based on gsap and now I am going to share it with you step by step.


1. First of all, in our Next.js application we need to install gsap library:

npm i gsap        

2. Then, we are going to create a custom hook - like this:

import { useEffect } from "react";

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

export const useOverlapping = (ref) => {
   useEffect(() => {
     const ctx = gsap.context(() => {
       const sections = gsap.utils.toArray(".section");
       const headerRef = document.querySelector("header");
       if (sections && headerRef) {
         sections.forEach((section) =>
           gsap.to(section, {
             scrollTrigger: {
               trigger: section,
               start: () =>
                 section.offsetHeight <=
                 window.innerHeight - headerRef.offsetHeight
                   ? `top ${headerRef.offsetHeight}px`
                   : "bottom bottom",
               endTrigger: ref.current,
               end: "bottom top",
               scrub: true,
               pin: section,
               pinSpacing: false,
             },
           })
         );
       }
     }, ref);

     return () => ctx.revert();
   }, [ref]);
};        

  • As you can see, the hook is based on useEffect hook from React library. Moreover, we also import gsap and ScrollTrigger plugin. The plugin is then registered via registerPlugin() method.

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);        

  • The useOverlapping custom hook callback receives a reference to the wrapper element as an argument.

export const useOverlapping = (ref) => {
       useEffect(() => {}, [ref])
}        

  • NB! A very important detail: Inside the useEffect callback all gsap-related code is used inside gsap context. There is no need for that when writing code with vanilla JavaScript, but it is strongly recommended when using frameworks like React or Next.js to insure stability and correctness of gsap performance.

useEffect(() => {
   const ctx = gsap.context(() => {}, ref);

   return () => ctx.revert();
}, [ref])        

Otherwise we would encounter strange and unpleasant behavior like sections "jumping" from top to bottom and changing their order or appearing of undesired spaces between sections etc.

Running gsap animation on Next.js without gsap context:

  • Then we have to get an array of section references. Therefore, each section must have a special className - in our case it is "section". Our example app will also have a fixed header - that is why we also get a reference to it.

const sections = gsap.utils.toArray(".section");
const headerRef = document.querySelector("header");        

  • Next, using forEach() method we apply gsap to each section, wherein we pass a section reference and an options object as two arguments. Inside the options object we will specify scrollTrigger settings.

sections.forEach((section) =>
         gsap.to(section, {
              scrollTrigger: {},
         })
);        

  • The trigger property specifies the element that will launch the animation. In our case, the trigger is the section itself.

trigger: section,        

  • The start property specifies when (i.e. at what conditions) the animation will be triggered. The value of this property can be either a simple string (e.g. "bottom top") or a callback if we need to set more elaborate conditions.

In our case, in the callback we state the following: if the current section offsetHeight is smaller or equal to viewport innerHeight minus header offsetHeight (as we also have a header) - then the animation will start when the top of the section reaches the point where it meets the bottom of the header. Otherwise, it will happen when the bottom of the section reaches the bottom of the viewport.

start: () =>
      section.offsetHeight <=
      window.innerHeight - headerRef.offsetHeight
      ? `top ${headerRef.offsetHeight}px`
      : "bottom bottom",        

  • The endTrigger property specifies the element that will stop the animation. In our case, the endTrigger is the wrapper element, the reference to which we have passed as an argument.

endTrigger: ref.current,        

  • The end property specifies when (i.e. at what conditions) the endTrigger will stop the animation. The value of this property can also be either a simple string (e.g. "bottom top") or a callback, but we use the former option here. This means that the animation of the section will stop when the bottom part of the wrapper element reaches the top of the viewport.

end: "bottom top",        

In other words it will never happen, because when the last section becomes fixed ("pinned") the bottom of the wrapper element will stop moving and it will never reach the top of the viewport.

  • After that, we need to specify which element we are going to pin (make fixed) when the animation is triggered. Here, it is the section itself, though we could apply the animation to any other elements if we wanted to. And we also want to remove spacing between sections.

pin: section,
pinSpacing: false,        

  • And the last part is the scrub property. When turned to true, it gives the user more control over the animation, as it is only going to run while the page is being scrolled. As soon as scrolling stops, the animation "freezes" in the current position. Though it is not very obvious with the pinning animation, the creators of gsap recommend using scrub with it as well.

scrub: true,        

  • During the development process we can also turn on special markers on the screen that can help understand the animation logic.

markers: true,        
Markers have been turned on

3. We are almost done! Now we just need to create a special component in our Next.js application where the useOverlapping hook will apply and which will wrap all sections as its children. In my case, I called it OverlapClient. With the useRef hook from React library we get the reference to the wrapper element (<div>) and pass this reference to the useOverlapping hook as an argument. Just don’t forget to give each section a "section" className and specify "use server" directive inside the sections to preserve server-side rendering, because OverlapClient is a client-side component.

"use client";

import { useRef } from "react";
import { useOverlapping } from "@/hooks/useOverlapping";

const OverlapClient = ({ children }) => {
  const wrapperRef = useRef();

  useOverlapping(wrapperRef);

  return (
    <div ref={wrapperRef} className="pb-16">
      {children}
    </div>
  );
};

export default OverlapClient;        

Aftermath

Unfortunately, on the project I started the whole story with, in the end we had to refuse from this solution, since despite good looks and smooth performance it had one serious drawback that you need to take into account.

When using the website navigation menu, we come across one problem. The overlapping effect allows fast scrolling down to the needed sections successfully, but you cannot use the navigation menu to scroll backwards as all sections are already there folded "in a pile". So you can only scroll up manually. And you should keep this in mind when planning your website functionality, if you want to apply this overlapping "parallax" effect.


And this is it! If you find this article useful or interesting - please leave a reaction. And if you have some important remarks, additions, objections or ideas and suggestions on how to improve the code - feel free to share them in the comments.

GitHub Source Code is Here

要查看或添加评论,请登录

社区洞察

其他会员也浏览了