Unsplash picture

Background

After browsing on one of my favorite websites awwwards.com I noticed that many websites use scroll animations to create stunning effects, but some websites use so many resources that the animation stutters.

When I came across a website from depo.studio I saw a nice image reveal effect on their about page. So I thought to challenge myself and try to implement it. And make sure that the animation runs smooth on any device.

For this project I wanted to use CSS as much as possible for performance reasons and managed to only use Javascript to set a --scroll-top variable which I will use to animate the squares on the screen.

10-01-2024

Tutorial

Let's start by initializing a new Next.js application.
You can do that by inserting the following npx command in your terminal by choice.

After initialization we can delete everything in the page.tsx, globals.css, page.module.css to start with a nice blank application.

terminal
  • npx create-next-app@latest
  • What is your project named? demo
  • Would you like to use TypeScript? Yes
  • Would you like to use ESLint? Yes
  • Would you like to use Tailwind CSS? No
  • Would you like to use `src/` directory? No
  • Would you like to use App Router? (recommended) Yes
  • Would you like to customize the default import alias (@/*)?) No

Basics

We should start by creating a basic HTML structure where the main viewport contains a section with a height of 200vh so that we can scroll. Within the section there will be a fixed div which scroll with the section.

Our grid will be rendered over our image which uses picsum.photos as image placeholder and contains three rows, note that the grid items are not visible, we will style te grid items later on.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
export default function Home() {
  return (
    <main>
      <div className="section">
        <div className="fixed">
          {/* I use picsum for a random image but you could use youre own image here */}
          <img
            src="https://picsum.photos/seed/100/1920/1080"
            className="image"
          />

          <div className="grid">
            {/* Simple function to generate 15 divs */}
            {Array.from({ length: 5 * 3 }).map((_, idx) => (
              <div key={idx} className="grid-item" />
            ))}
          </div>
        </div>
      </div>
    </main>
  );
}

Inverse corners

Unfortunately within CSS there is no easy way to to get inverse corners on a div, but there is a way to do it. I've used multiple box-shadows without a blur and overflow hidden. So let's say you have a div with a corner-radius of 10px and you want a inverse corner on the top left you could add box-shadow: -10px -10px 0px; and luckily for us box-shadow supports multiple entries so for all the corners it is as easy as repeating the logic.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
html, body {
  margin: 0;
  padding: 0;
}

/* The main viewport */
main {
  max-height: 100vh;
  overflow-x: hidden;
}

/* The first seciton so we can scroll the page */
.section {
  height: 200vh;
}

/* Here we create a fixed window so the image and grid stays in place when we scroll */
.fixed {
  position: sticky;
  height: 100vh;
  top: 0;
}

/* Scale image to full screen and scale properly */
.image {
  position: absolute;
  inset: 0;
  object-fit: cover;
  object-position: center;
}

/* The grid which should hold the squares */
.grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(3, 1fr);
  height: 100%;
}

/* Grid should be relative so the pseudo-element is absolute to there grid item and ignore overlaying content */
.grid-item {
  position: relative;
  overflow: hidden;
}

/* Some basic grid item with a default value for --size */
.grid-item::before {
  --size: 1rem;
  content: "";
  position: absolute;
  inset: var(--size);
  border-radius: var(--size);
  box-shadow: 
        calc(-1 * var(--size)) calc(-1 * var(--size)) 0 var(--size), /* top-left -1rem -1rem 0 1rem */
        var(--size) calc(-1 * var(--size)) 0 var(--size), /* top-right 1rem -1rem 0 1rem */
        var(--size) var(--size) 0 var(--size), /* bottom-right 1rem 1rem 0 1rem */
        calc(-1 * var(--size)) var(--size) 0 var(--size); /* bottom-left -1rem 1rem 0 1rem */
}

The animation

Now let's create the animation! We want to change the --size based on our scroll position. If we are a the top of our page the --size should be 1rem and after 100vh it should be zero so the image is completely visible for another 100vh.

To track our scroll position i've created a custom React hook which ads scroll event listeners to a reference container. With the interpolate method I can apply my own logic to translate the scroll position into variables.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
import { useLayoutEffect, useRef } from "react";

/*
  * This function will add "scroll" event listeners to it's given container
  * and calls the given interpolate method when te element scrolls
  */
function useScrollObserver(interpolate: (element: HTMLElement) => void) {
  const containerRef = useRef(null);

  useLayoutEffect(() => {
    // return if container is not set
    if (!containerRef?.current) {
      return;
    }

    // grep current container as HTMLElement
    const container: HTMLElement = containerRef!.current;

    // bind interpolate method with current container
    const listener = () => interpolate(container);

    // add "scroll" listener
    container.addEventListener("scroll", listener);

    // call listener on component mount
    listener();

    return () => {
      // remove listener on component unmount
      container!.removeEventListener("scroll", listener);
    };
  }, [containerRef, interpolate]);

  // return target container reference
  return containerRef;
}

export default useScrollObserver;