Build a Smooth Scroll Landing Page using Next.js, GSAP, and Locomotive Scroll v5.
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.
Adding the HTML and CSS
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.
This landing page will be composed of 4 components:
- Page Component: The parent, initializes the Locomotive Scroll and imports the other components.
- Intro Component: The first section, features a background image with a
clip-pathanimation and a body with an animated title and image. - Description Component: The second section, features scroll-animated paragraphs.
- Projects Component: The last section, features a pinned image and a state-based project gallery.
The 3 sub-components are created inside a /src/components/ folder.
The Page Component
The page component is the parent of the other 3 components. It also manages the smooth scroll, which is easily done with Locomotive Scroll v5.
page.js
"use client"
import { useEffect } from "react"
import Description from "../components/Description"
import Intro from "../components/Intro"
import Projects from "../components/Projects"
import styles from "./page.module.css"
export default function Home() {
useEffect(() => {
;(async () => {
const LocomotiveScroll = (await import("locomotive-scroll")).default
const locomotiveScroll = new LocomotiveScroll()
})()
}, [])
return (
<main className={styles.main}>
<Intro />
<Description />
<Projects />
</main>
)
}Note: The Locomotive Scroll is purely a client-side library, so we need to import it asynchronously after the component mounts. If we don't do this, we will receive an error mentioning that
windowdoes not exist.
With that, we now have a native smooth scroll, amazing!
The Intro Component
The Intro is composed of 3 main elements:
- The background image: Animated with ScrollTrigger, using the
clip-pathproperty. - The main image: Animated with ScrollTrigger using the
heightproperty and Locomotive Scroll withdata-scroll-speed. - The title: Animated with Locomotive Scroll with
data-scroll-speed.
Intro/index.jsx
"use client"
import React, { useLayoutEffect, useRef } from "react"
import Image from "next/image"
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import styles from "./style.module.css"
export default function Index() {
const background = useRef(null)
const introImage = useRef(null)
const homeHeader = useRef(null)
useLayoutEffect(() => {
gsap.registerPlugin(ScrollTrigger)
const timeline = gsap.timeline({
scrollTrigger: {
trigger: document.documentElement,
scrub: true,
start: "top",
end: "+=500px",
},
})
timeline
.from(background.current, { clipPath: `inset(15%)` })
.to(introImage.current, { height: "200px" }, 0)
}, [])
return (
<div ref={homeHeader} className={styles.homeHeader}>
<div className={styles.backgroundImage} ref={background}>
<Image
src={"/images/background.jpeg"}
fill={true}
alt="background image"
priority={true}
/>
</div>
<div className={styles.intro}>
<div
ref={introImage}
data-scroll
data-scroll-speed="0.3"
className={styles.introImage}
>
<Image
src={"/images/intro.png"}
alt="intro image"
fill={true}
priority={true}
/>
</div>
<h1 data-scroll data-scroll-speed="0.7">
SMOOTH SCROLL
</h1>
</div>
</div>
)
}Here's the result:
The Description Component
The Description component features an internal component called AnimatedText. An array of phrases is mapped to render each line.
Inside it, a new ScrollTrigger is created from the top of each paragraph and ends at +=400px. The left offset value and the opacity are adjusted during the scroll.
Description/index.jsx
import React, { useLayoutEffect, useRef } from "react"
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import styles from "./style.module.css"
const phrases = [
"Los Flamencos National Reserve",
"is a nature reserve located",
"in the commune of San Pedro de Atacama",
"The reserve covers a total area",
"of 740 square kilometres (290 sq mi)",
]
export default function Index() {
return (
<div className={styles.description}>
{phrases.map((phrase, index) => {
return <AnimatedText key={index}>{phrase}</AnimatedText>
})}
</div>
)
}
function AnimatedText({ children }) {
const text = useRef(null)
useLayoutEffect(() => {
gsap.registerPlugin(ScrollTrigger)
gsap.from(text.current, {
scrollTrigger: {
trigger: text.current,
scrub: true,
start: "0px bottom",
end: "bottom+=400px bottom",
},
opacity: 0,
left: "-200px",
ease: "power3.Out",
})
}, [])
return <p ref={text}>{children}</p>
}Here's the result:
The Projects Component
The Projects component features standard layout styling with a pinned sidebar animation.
There is an internal state to track which project is currently highlighted via hover, which dynamically updates the source URL of the showcased image.
Projects/index.jsx
import React, { useLayoutEffect, useRef, useState } from "react"
import Image from "next/image"
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import styles from "./style.module.css"
const projects = [
{
title: "Salar de Atacama",
src: "salar_de_atacama.jpg",
},
{
title: "Valle de la luna",
src: "valle_de_la_muerte.jpeg",
},
{
title: "Miscanti Lake",
src: "miscani_lake.jpeg",
},
{
title: "Miniques Lagoons",
src: "miniques_lagoon.jpg",
},
]
export default function Index() {
const [selectedProject, setSelectedProject] = useState(0)
const container = useRef(null)
const imageContainer = useRef(null)
useLayoutEffect(() => {
gsap.registerPlugin(ScrollTrigger)
ScrollTrigger.create({
trigger: imageContainer.current,
pin: true,
start: "top-=100px",
end: document.body.offsetHeight - window.innerHeight - 50,
})
}, [])
return (
<div ref={container} className={styles.projects}>
<div className={styles.projectDescription}>
<div ref={imageContainer} className={styles.imageContainer}>
<Image
src={`/images/${projects[selectedProject].src}`}
fill={true}
alt="project image"
priority={true}
/>
</div>
<div className={styles.column}>
<p>
The flora is characterized by the presence of high elevation
wetland, as well as yellow straw, broom sedge, tola de agua and tola
amaia.
</p>
</div>
<div className={styles.column}>
<p>
Some, like the southern viscacha, vicuña and Darwins rhea, are
classified as endangered species. Others, such as Andean goose,
horned coot, Andean gull, puna tinamou and the three flamingo
species inhabiting in Chile (Andean flamingo, Chilean flamingo, and
Jamess flamingo) are considered vulnerable.
</p>
</div>
</div>
<div className={styles.projectList}>
{projects.map((project, index) => {
return (
<div
key={index}
onMouseOver={() => {
setSelectedProject(index)
}}
className={styles.projectEl}
>
<h2>{project.title}</h2>
</div>
)
})}
</div>
</div>
)
}Here's the result:
Wrapping up
We're officially done with this one-pager!
Hope you liked this tutorial. I've seen similar layouts on many award-winning portfolios, so it's a great technique to learn.
- NarakCODE