James McGrath

Fly in animation on scroll with CSS and JavaScript

Apr 24, 2022
6 minutes

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.