Initializing the project
Let's start the project by creating a Next.js application. We can do that by running npx create-next-app@latest client
inside of a terminal.
We can delete everything in the page.js
, global.css
and page.module.css
and add our own HTML and CSS, to start with a nice blank application.
- We will use Sass for the stylesheets, so we can run
npm i sass
. - We will use Framer Motion for the animation, so we can run
npm i framer-motion
. - We will use the Lenis Scroll for the smooth scrolling, so we can run
npm i @studio-freight/lenis
.
Rendering the Sticky Cards
The first thing I set up to render the cards in a more efficient way, is to use an array of object. That way I can simply map it and return everything I need. Here's how it looks:
1
export const projects = [
3
title: "Matthias Leidinger",
4
description: "Originally hailing from Austria, Berlin-based photographer Matthias Leindinger is a young creative brimming with talent and ideas.",
6
link: "https://www.ignant.com/2023/03/25/ad2186-matthias-leidingers-photographic-exploration-of-awe-and-wonder/",
10
title: "Clément Chapillon",
11
description: "This is a story on the border between reality and imaginary, about the contradictory feelings that the insularity of a rocky, arid, and wild territory provokes”—so French photographer Clément Chapillon describes his latest highly captivating project Les rochers fauves (French for ‘The tawny rocks’).",
13
link: "https://www.ignant.com/2022/09/30/clement-chapillon-questions-geographical-and-mental-isolation-with-les-rochers-fauves/",
18
description: "Though he views photography as a medium for storytelling, Zissou’s images don’t insist on a narrative. Both crisp and ethereal, they’re encoded with an ambiguity—a certain tension—that lets the viewer find their own story within them.",
20
link: "https://www.ignant.com/2023/10/28/capturing-balis-many-faces-zissou-documents-the-sacred-and-the-mundane-of-a-fragile-island/",
Page.js
Then I can use that array inside the page.js
to render all the cards:
2
import styles from './page.module.css'
3
import { projects } from '../data';
4
import Card from '../components/Card';
6
export default function Home() {
8
<main className={styles.main}>
10
projects.map( (project, i) => {
11
return <Card key={`p_${i}`} {...project} i={i}/>
Here I also externalize the code inside a new Card
component.
Card Component
The card component is simple HTML and CSS for now, they each take 100vh
of height and they have a sticky
position to make them stick when they reach the top of the window.
2
import Image from 'next/image';
3
import styles from './style.module.scss';
5
const Card = ({title, description, src, url, color, i}) => {
8
<div className={styles.cardContainer}>
10
className={styles.card}
11
style={{backgroundColor: color, top:`calc(-5vh + ${i * 25}px)`}}
14
<div className={styles.body}>
15
<div className={styles.description}>
18
<a href={url} target="_blank">See more</a>
19
<svg width="22" height="12" viewBox="0 0 22 12" fill="none" xmlns="http://www.w3.org/2000/svg">
20
<path d="M21.5303 6.53033C21.8232 6.23744 21.8232 5.76256 21.5303 5.46967L16.7574 0.696699C16.4645 0.403806 15.9896 0.403806 15.6967 0.696699C15.4038 0.989592 15.4038 1.46447 15.6967 1.75736L19.9393 6L15.6967 10.2426C15.4038 10.5355 15.4038 11.0104 15.6967 11.3033C15.9896 11.5962 16.4645 11.5962 16.7574 11.3033L21.5303 6.53033ZM0 6.75L21 6.75V5.25L0 5.25L0 6.75Z" fill="black"/>
25
<div className={styles.imageContainer}>
26
<div className={styles.inner}>
29
src={`/images/${src}`}
- Line 11: A dynamic top position is set depending on the index of each cards, creating a simple stacking effect. Also note that's how the color of each card is set.
We should have something like this:
On Scroll Image Scaling
To scale each image on scroll, I'll use Framer Motion and the useScroll hook and the useTransform The general concept is the more we scroll, the more I want to scale the image, here's how I did it:
components/Card/index.jsx
2
import { useTransform, useScroll, motion } from 'framer-motion';
3
import { useRef } from 'react';
5
const Card = ({title, description, src, url, color, i}) => {
7
const container = useRef(null);
8
const { scrollYProgress } = useScroll({
10
offset: ['start end', 'start start']
12
const imageScale = useTransform(scrollYProgress, [0, 1], [2, 1])
15
<div ref={container} className={styles.cardContainer}>
17
className={styles.card}
18
style={{backgroundColor: color, top:`calc(-5vh + ${i * 25}px)`}}
21
<div className={styles.imageContainer}>
23
className={styles.inner}
24
style={{scale: imageScale}}
28
src={`/images/${src}`}
- Line 8-11: Here I use the useScroll hook to track whenever the card enters the viewport. It returns a value between
0
and 1
depending, starting at the intersection of the top of the card container and the end of the window 'start end'
and ending at the intersection of the top of the card container and the start of the window 'start start'
- Line 12: I use the useTransform hook to transform the value of the progress of the scroll
[0,1]
into a new value that I'll use for the scale of the image [2,1]
We should have something like this:
Cards Parallax
To create the parallax, I use the same hooks as I did for the image scaling. However, instead of being localized inside a Card
component, it will be global for the whole animation. So I'll use the useScroll
inside the page.js
for that.
2
import styles from './page.module.scss'
3
import { projects } from '../data';
4
import Card from '../components/Card';
5
import { useScroll } from 'framer-motion';
6
import { useRef } from 'react';
8
export default function Home() {
9
const container = useRef(null);
10
const { scrollYProgress } = useScroll({
12
offset: ['start start', 'end end']
16
<main ref={container} className={styles.main}>
18
projects.map( (project, i) => {
19
const targetScale = 1 - ( (projects.length - i) * 0.05);
20
return <Card key={`p_${i}`} i={i} {...project} progress={scrollYProgress} range={[i * .25, 1]} targetScale={targetScale}/>
- Line 10: Tracks the whole animation, it returns a value between
0 and 1
, 0 being when the top of the main intersects with the top of the window 'start start'
and 1 being when the end of the main intersects with the end of the window 'end end'
. - Line 19: A scale value that is given to the card, the first cards should have a higher target scale than the last ones.
Adjusting the Card Component
components/Card/index.jsx
1
const Card = ({i, title, description, src, url, color, progress, range, targetScale}) => {
3
const scale = useTransform(progress, range, [1, targetScale]);
6
<div ref={container} className={styles.cardContainer}>
8
className={styles.card}
9
style={{backgroundColor: color, scale, top:`calc(-5vh + ${i * 25}px)`}}
- Line 3: Here the scaling of each card is dictated by the progress of the scroll and the targetScale initialized inside the page.js.
We should have something like this:
Adding a Smooth Scroll
The final touch of this animation is to make everything smooth. Since the animation is fixed to the scroll, we can make it smooth by smoothing out the scroll. For that I use the Lenis Scroll
2
import { useRef, useEffect } from 'react';
3
import Lenis from '@studio-freight/lenis'
5
export default function Home() {
8
const lenis = new Lenis()
12
requestAnimationFrame(raf)
15
requestAnimationFrame(raf)
19
<main ref={container} className={styles.main}>
We should have something like this
Wrapping up
That's it for this animation!
A very common animation, found in multiple awwwards website, and this is a nice, simple approach to it! Hope you learned something.
-Oli