CSS Parallax “Explore Space” effect with HTML, CSS, JS (scroll animation)

Krishna Prasad
11 min readMar 29, 2020

Hi y’all, hope you’re doing good. I am excited to share this article with you all. It is an animation that you see in the gif above. I have achieved the transitions using simple CSS transform properties timed right, though, the challenge was more with how the scrolling affects it. Yes, you heard it right! Every bit of the animation (transitions) you see happening above occur on individual scrolls! No hover, No click but only Scroll!

Kudos to my good friend Punith Bn for this interactive design.
Here’s a link to a live demo of this animation —TheCodeAddict.Com/Explore-Space

As usual, I will be skipping most parts of the templating and styling as requirements change. Albeit, since this is a mostly CSS animation, I will explain the crucial CSS parts as best as I can.
You will find a link to all the files as a Github gist at the end of the post!

Alright then, without further due, buckle up and let’s get started —

Sections to cover —
1. Templates, styles and understanding the section-wise flow
2. Initial state — a “fixed” first fold to animate on scroll, but, without actually scrolling the page (the main parallax effect)
3. Fold One — the actual “animation” from the initial state on first scroll down
4. Fold Two — a “100vh” scroll down of the page to reveal the second fold with the three planet cards conjoining
5. Fold Three — Back to the state of Fold One
6. Fold Four — Back to initial state

Languages / Frameworks —
HTML5 (JSX), CSS3 (SCSS), ReactJS + Next.JS for Javascript
I assume that you know ReactJS + Next.JS — if you do, you can skip the block-quotes, else you have pointers over there for a basic understanding of the template :)

Assets required to fulfil the animations
3 images for the first fold —

I used masked images. You can choose to use unmasked pngs.

and 3 for the second!

Jupiter, Neptune and Saturn! You could also use Pluto!

Since we have two folds worth content, it wouldn’t be a good idea to put everything in absolute positioning. So I have a main container for the first fold that is a relative position with a “100vh” height, and a second fold container with the same attributes —

<div id="section-1" className="parallax-container"></div>
<div id="section-2" className="planets-container"></div>

Don’t panic about the “className” attribute (for those that don’t know), that belongs to ReactJS and it translates to the html “class” attribute.

With their corresponding styles —

.parallax-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
.planets-container {
position: relative;
height: 100vh;
padding: 100px 200px;
}

Notice the “overflow: hidden” property on the parallax container? That is useful for restricting the overflowing images when using transform: translate.

This is a bunch of usual mark up and styling which I will only add the points to be noted here (you can find the complete styling in the gist linked at the end of the post). So here’s the Initial State of the animation —

The HTML<div className={`background-3 ${passed ? "scale" : ""}`}></div>
<div className={`background-1 ${passed ? "scale" : ""}`}></div>
<div className={`background-2 ${passed ? "scale" : ""}`}></div>
<div className={`text-block ${moveRight ? 'flow-right' : passed ? "scale" : ""}`}>
<p className="main">EXPLORE</p>
<p className="sub">THE SPACE</p>
</div>

The “className={`background-3 ${passed ? “scale” : “”}`}” is a way of injecting a class dynamically to an element in React. Basically, we are injecting / appending a class called “scale” if a boolean “passed” is in the “true” state.

You might be wondering why background-3 is on the top? It doesn’t have to be! I was trying a lot of other methods to get animations working and then I left it as is. You can have that placed in any order since we need a z-index only on background-3 which will force it to be on the top all the time. The other 2 though will hamper the z-index if you swap the positions and we don’t want that happening as we need the planet to be “in” the cosmos and not behind :D

Okay, back to the animation. I have a 2 level animation here —
1. “scale” — background-1 will zoom out, background-2 and background-3 zoom in and the text slides in from bottom; behind background-3 (hence the z-index: 1 mentioned above)

The relevant CSS —

.background-1 {
background: url("/spaceExplore/background1.png");
transform: scale(1.4);
transition: transform 1.5s ease;
@extend .background-const;
&.scale {
transform: scale(1);
}
}
.background-2 {
background: url("/spaceExplore/background1.png");
transition: transform 1.5s ease;
@extend .background-const;
&.scale {
transform: scale(1.4);
}
}
.background-3 {
background: url("/spaceExplore/background1.png");
transition: transform 1.5s ease;
@extend .background-const;
&.scale {
transform: scale(1.4);
}
}

Okay, a few things to note here — only background-1 starts with a scale of 1.4 because we need that layer to zoom out. If you zoom out from a scale of 1, it will not cover the screen width and height, so you scale from 1.4 to 1!

All the transitions are timed to 1.5s and scales are set to 1.4. If you are new to SCSS and don’t know about the @extend property mentioned in the style, I have linked it to the w3schools document, however, as a mention, it is used to extend / share the CSS properties available in the .background-const class. There are a few attributes that are common between the three blocks which I have in the aforementioned shared class —

.background-const {
background-position: center !important;
background-size: cover !important;
background-repeat: no-repeat !important;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
}

The text however does not scale and instead, we move it around using the transform: translate(x, y) attribute —

.text-block {
position: absolute;
transform: translate(0, 100%);
transition: transform 1.5s ease;

&.flow-right {
transform: translate(100%, 0);
}

&.scale {
transform: translate(0, 0);
}
}

Why do you have 2 classes “flow-right” and “scale”? That’s a good question. flow-right is a transition that happens on the second scroll if you observe in detail. The text moves left-right.

With that cleared, you have successfully written the template for both fold-1 and the animated fold-2. CHEERS!

Here’s the Fold-3 / section-2 markup and styles —

<div id="section-2" className="planets-container">
<div id="bringTogether" className="h-100 planets-centered row">
<div className={`h-100 neptune col-6 ${together ? "together" : ""}`}>
<div id="section2" className="h-100 neptune-bg">
<p>Neptune</p>
<img
src="/spaceExplore/neptune.png"
className="h-100 invisible"
alt=""
/>
</div>
</div>
<div className="col-6 h-100 right-planets">
<div className={`jupiter ${together ? "together" : ""}`}>
<div className="h-100 jupiter-bg">
<p>Jupiter</p>
<img
src="/spaceExplore/jupiter.png"
className="h-100 invisible"
alt=""
/>
</div>
</div>
<div className={`saturn ${together ? "together" : ""}`}>
<div className="h-100 saturn-bg">
<p>Saturn</p>
<img
src="/spaceExplore/saturn.png"
className="h-100 invisible"
alt=""
/>
</div>
</div>
</div>
</div>
</div>

If you did read that template code, I think I know what your next question is. Why is there an img tag with “invisible” class? The invisible is a bootstrap class that adds in visibility: hidden; CSS attribute to the element. I have this way of adding an invisible image under the background image so it is easy to manipulate the size of the background image to fit the required height and width without having to position them absolutely. This is just one method that I prefer when I need to play with image centric functionalities, like this parallax or some animated carousels etc.

Aaand it’s respective CSS (note that this isn’t the complete CSS, you will find it in the gist below)—

.neptune {
transform: translateX(-20%) scale(0.6);
transition: transform 1s ease;
}
.saturn {
height: 48%;
transform: translate(20%, 10%) scale(0.6);
transition: transform 1s ease;
}
.jupiter {
height: 48%;
transform: translate(20%, -10%) scale(0.6);
transition: transform 1s ease;
}

Okay, I believe you already know what’s happening here. So to summarise, Neptune slides in from the left, Saturn slides in from the bottom-right and Jupiter from the top-right.

And there you go, this completes all the templates you would require for this to work. Now, let’s get to the states and hooks of React to breathe life to this.

First thing first, I work with function components and hooks for lifecycle hooks. So let us see the states that we will require for this effect to work —

const [passed, setPassed] = useState(false)
const [step, setStep] = useState(0)
const [fire, setFire] = useState(true)
const [together, setTogether] = useState(false)
const [moveRight, setMoveRight] = useState(false)

useState() is a react hook that takes in 2 arguments that are used to read / write states. Refer this doc for more info.

passed is to know if it has passed the first phase of the scrolling. This is where we remove the overflow hidden attribute on the body to allow the next scroll to actually go to the second section.
step is just a count of the number of scrolls that have taken place. We will need this to move / setPassed state to the right value.
Fire is the state that will support the magic. This is the guy I use to stop scroll event from triggering more than once!
together now this is the state where the planets in the second fold come together
moveRight this one just moves the text left-right after the first and third scrolls.

Now that we have our states in place, first thing to do is on component mount, set the body’s overflow to hidden so we stay on the first fold itself.

useEffect(() => {
if (step === 0) {
setBodyOverflow("hidden");
}
})
const setBodyOverflow = val => {
document.getElementsByTagName("body")[0].style.overflow = val;
};

The reason I have for setting the overflow in a different function is because we will reuse this over and over again.

To detect the scroll on a “overflow: hidden” block is a bit tricky as the eventListener “scroll” would not work as expected. This is where we use the event “wheel”. Also the wheel event has an attribute called “deltaY”; this is specifically useful for us to determine the direction of scroll — it is as simple as if deltaY>0, you are scrolling down, else you are scrolling up.

To use this, let’s setup the listener in our useEffect hook above —

useEffect(() => {
if (step === 0) {
setBodyOverflow("hidden");
window.addEventListener("wheel", fire ? scrollHandler : () => {});
}
})

One thing to remember here is whenever you add a Listener in the useEffect hook (which relates to componentDidMount), you also have to remove it in componentDidUnmount so there is no memory leak issues. To do this, we will have to return it inside the hook which will relate to componentDidUnmount like so —

useEffect(() => {
if (step === 0) {
setBodyOverflow("hidden");
window.addEventListener("wheel", fire ? scrollHandler : () => {});
return () => {
window.removeEventListener("wheel", scrollHandler);
}
}
})

The scrollHandler is a callback function that triggers on the wheel event. Our goal is to stop the scroll effect after it triggers once. Usually with scroll, it triggers multiple times when you perform a “scroll” on your mouse / trackpad. How do we handle the states of scroll? Here’s the full callback for you to understand this.

const scrollHandler = event => {
setFire(false);
setTimeout(() => {
setFire(true);
}, 1500);
if (event.deltaY < 0) {
if (step > 0) {
setMoveRight(false)
setStep(step => step - 1);
setTogether(false);
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
if (passed && step === 1) {
setPassed(false);
setBodyOverflow("hidden");
}
}
} else if (event.deltaY > 0) {
if (step < 3) {
setStep(step => step + 1);
setPassed(true);
if (step === 1) {
setMoveRight(true)
setBodyOverflow("auto");
setTogether(true);
window.scrollTo({
top: 1000,
left: 0,
behavior: 'smooth'
});
}
}
}
};

Let me explain the above code. An event is passed to the callback function. First thing first, we need to block the upcoming scroll triggers — we do this by setting the fire state to false. This doesn’t mean that it should not scroll after this, so we set a timeout and re-enable fire to true. The timeout I have given is the time it takes for the parallax transform to complete (1.5s).

Secondly, we need to know the direction of the scroll as we need to roll back to the previous UI states on scrolling upwards. The animation parts are simply handled by reverting the classes we have handled as the CSS transition property works both ways! So essentially all we need to do is add / remove the classNames.

To start explaining with the second condition => if (deltaY > 0). This block of code is executed on scroll down. To note the first condition where it states if (step > 3) is because I have only a 3 step transition and after that, you are only allowed to go back. The transitions on the other side of the scroll would fail if this “step” increases count to more than 3.

On the first scroll, we increase the step count by one and set passed true. Setting passed here would add the .scale class as necessary which would initiate the first level of transition.

On the second scroll, we set the moveRight to true which would move the text out of the container to the right side by adding the class .flow-right, whilst scrolling down to the second section by setting body scroll to “auto”. Now once we are in the second section, we also need the second section to come together. This is handled by setting together state to true, which adds .together class to the planets triggering the animation. You could also scroll window by 1000 which would scroll your window by a 100vh. That finishes the second section of transition.

On the third however, if you scroll down, nothing happens as the count doesn’t increase. But the scroll does happen with no changes to our states. When you scroll up, we do the exact opposite of this flow to reverse engineer the transition back to its initial state and that’s all there is to it.

I hope I have made this post simple. Please get in touch with me if you have any clarifications or suggestions to make this better.

As promised, here’s the link to the gist where you can find all the files with complete code — Github Gist

Wow, it feels so good to write again. Thank you for staying with me and reading this through. You guys are awesome ❤. Please share any comments you have to make on this and let me know if you guys think this could have been better in any way. Always love learning from the community. Cheers guys! Have a good time. Signing off.

--

--

Krishna Prasad

ReactJS, NextJS, NodeJS and every other JS! It’s like a never ending learning journey. 😎