Fly in animation on scroll with CSS and JavaScript
Let’s make a fly in animation on scroll with css custom properties and the intersection observer.
Here’s a codepen of what we will be making. It’s not really refined and has few issues.
See the Pen Animate on scroll with intersection observer by James (@jamcgrath) on CodePen.
The HTML
Let’s start with the HTML. It’s a basic design with 4 sections. A title in the first section. The second section has a grid of cards that will fly up when the user scrolls. The third section has text on the left and an image that will fly-in from the right. The last section just has some text and no animation.
<div class="container">
<section class="section-1">
<h1>Animate on scroll</h1>
<p>
Let's see if we can can animate on scroll with intersection observer
</p>
</section>
<section class="section-2">
<h2 class="section-title">Steps to make this</h2>
<div class="grid card-grid intersection-sentry">
<div class="card">
<h3 class="card-title">First Design with no js</h3>
<p class="card-text">
First thing we are going to do is make the page with no js
</p>
</div>
<div class="card">
<h3 class="card-title">Then figure out positions</h3>
<p class="card-text">
Now we decide how we want to animate and set the position
with CSS transform, opacity and transition
</p>
</div>
<div class="card">
<h3 class="card-title">Finally do the JS</h3>
<p class="card-text">
Add an intersection observer to update the custom properties
to make it animate
</p>
</div>
</div>
</section>
<section class="section-3">
<div class="grid grid-row intersection-sentry">
<div class="grid-row-content">
<h2 class="section-title">
Possible things that could be done to improve
</h2>
<p>
Could watch for a no-js class on the body and reset
everything into the correct position and maybe add a
pollyfill for intersection observer
</p>
</div>
<img
class="grid-row-image"
src="https://images.unsplash.com/photo-1536098561742-ca998e48cbcc?crop=entropy&cs=srgb&fm=jpg&ixid=MnwxNDU4OXwwfDF8cmFuZG9tfHx8fHx8fHx8MTY1MDE2ODQ1NQ&ixlib=rb-1.2.1&q=85"
alt=""
/>
</div>
</section>
<section class="section-4">
<h2>That's it for now</h2>
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Voluptate
sit dolores officia beatae suscipit voluptates veritatis cumque
repellendus eaque, praesentium, odit vero, harum non illum debitis
officiis dolorem veniam ipsam.
</p>
</section>
</div>
The CSS
The CSS uses custom properties because I like them. After the block of CSS I’ll highlight some of the decisions I made.
:root {
--color-1: #171123;
--color-2: #372248;
--color-3: #414770;
--color-4: #5b85aa;
--color-5: #f46036;
--white: #fff;
--black: #212427;
--bg: var(--color-2);
--base-fs: 1rem;
--fs: var(--base-fs);
}
:is(h1, h2, h3) {
font-size: var(--fs);
text-align: center
line-height: 1;
}
h1 {
--fs: calc(var(--base-fs) * 2.5);
}
h2 {
--fs: calc(var(--base-fs) * 2);
}
h3 {
--fs: calc(var(--base-fs) * 1.5);
}
section {
color: var(--white);
background: var(--bg);
min-height: 80vh;
font-size: var(--fs);
padding: 1.5em;
display: grid;
place-content: center;
}
.section-1 p {
text-align: center;
margin-top: 3em;
}
.section-2 {
--bg: var(--color-3);
}
.section-3 {
--bg: var(--color-4);
}
.section-4 {
--bg: var(--color-5);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
}
.card-grid {
--translate-init: translate(0, 75px);
--translate: var(--translate-init);
--opacity: 0;
gap: 1.25em;
margin-top: 3em;
transform: var(--translate);
transition: var(--transition);
opacity: var(--opacity);
width: 100%;
max-width: 80vw;
}
@media (min-width: 650px) {
.card:last-of-type {
grid-column: span 2;
}
}
@media (min-width: 988px) {
.card:last-of-type {
grid-column: auto;
}
}
.card {
text-align: var(--text-align);
line-height: 1.5;
background: var(--white);
color: var(--color-1);
padding: 1.5em;
border-radius: 1em;
box-shadow: 1px 4px 8px var(--color-1);
}
.grid-row {
--translate-init: translate(60px, 0);
--translate: var(--translate-init);
--opacity: 0;
max-width: 1440px;
width: 100%;
place-content: center;
gap: 2em;
}
.grid-row-content {
width: 100%;
max-width: 60ch;
text-align: var(--text-align);
align-self: center;
}
.grid-row-image {
max-width: 400px;
width: 100%;
transform: var(--translate);
transition: var(--transition, 0);
opacity: var(--opacity, 0);
justify-self: center;
}
.section-4 p {
max-width: 100ch;
}
/* disable animation for people who have the reduced motion in accessibility settings. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: 0 !important;
transform: translate(0) !important;
opacity: 1 !important;
}
}
The first thing of interest is the is()
. I’m kind of templating the h1, h2, h3
. As you can see below the is()
I’m scaling the font size of the h tags by multiplying them. Ideally I would prefer to use the :where()
pseudo selector because it has 0 specificity but I needed to override the normalize css that is being used in the codepen.
The next thing to look at in the CSS is the .card-grid
and .card-row
. Both have the same custom properties scoped to themselves. They are the same so the intersection observer can easily change the the values that animate without having to do any conditional statements. Although the values are not applied directly to the image, the image will inherit it because of the cascade. There is also an --translate-init
property whose sole purpose is to make the reset easier. I could of done the same for --transition
and --opacity
but the values are the same for both the .card-grid
and .card-row
classes and they are only switching between 2 values.
The last thing to look at is the @media (prefers-reduced-motion: reduce)
media query. This checks if the user has reduced motion set in their accessibility settings. If they do, the animation will be disabled.
## The JS
```js
const options = {
rootMargin: "50%"
};
const handleIntersect = (entries, observer) => {
entries.forEach((entry) => {
const target = entry.target;
if (entry.isIntersecting) {
console.log(entry);
target.style.setProperty("--transition", "all .75s");
target.style.setProperty("--opacity", "1");
target.style.setProperty("--translate", "translate(0)");
} else {
target.style.setProperty("--transition", "0");
target.style.setProperty("--translate", "var(--translate-init)");
target.style.setProperty("--opacity", "0");
}
});
};
const intersectorSentries = document.querySelectorAll(".intersection-sentry");
const observer = new IntersectionObserver(handleIntersect, options);
intersectorSentries.forEach((sentry) => observer.observe(sentry));
The last thing to do is setup the intersection observer to handle the animations. The code seems straightforward to me. It sets the intersection observer to observe all the elements with .intersection-sentry
class. When it intersects, it sets the custom properties to animate the element and un-sets when it is not intersecting.