profile picture of Olivier Larose

Olivier Larose

July 24, 2023

/

Intermediate

/

Long

Awwwards Landing Page

Rebuild an Awwwards Landing page with Nextjs, Framer Motion and GSAP

An Awwwards portfolio landing page rebuild. Originally made by Dennis Snellenberg, he won an awwwards with his amazing portoflio. Remade the landing page using Next.js, Framer Motion and GSAP. See the original: https://dennissnellenberg.com/

Live DemoSource codeVideo Tutorial
background video

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.

Here's a list of all the libraries we need for this landing page:

  • Sass for the stylesheets: npm i sass.
  • Framer Motion for animations: npm i framer-motion.
  • GSAP for animations: npm i gsap.
  • Locomotive Scroll for smooth scroll: npm i npm install locomotive-scroll@beta.

Initializing the Page

The root page will be the one initializing the smooth scroll and importing all the other components:

page.js

globals....

1

'use client';

2

import { useEffect } from 'react'

3

4

export default function Home() {

5

6

useEffect( () => {

7

(

8

async () => {

9

const LocomotiveScroll = (await import('locomotive-scroll')).default

10

const locomotiveScroll = new LocomotiveScroll();

11

}

12

)()

13

}, [])

14

15

return (

16

<main>

17

{/* <AnimatePresence mode='wait'>

18

{isLoading && <Preloader />}

19

</AnimatePresence>

20

<Landing />

21

<Description />

22

<Projects />

23

<SlidingImages />

24

<Contact /> */}

25

</main>

26

)

27

}

28

Landing, Projects, Header Components

I'm going to grab the code that I previously made for these 3 components.

I already have a tutorial specifically for these animations. You can check them out here:

We should have something like this:

Header Component

Here we will add the header. It will be a normal header with 3 navigations items, but when scrolling for more than 100vh, it will be swapped for a burger menu.

  • We use ScrollTrigger by GSAP to create this transition.
  • We also use CSS to create the logo animation.
  • For the curved menu, you check it out here.

Header/i...

Header/s...

1

'use client';

2

import { useEffect, useLayoutEffect, useRef, useState } from 'react';

3

import styles from './style.module.scss';

4

import { usePathname } from 'next/navigation';

5

import { AnimatePresence } from 'framer-motion';

6

import { ScrollTrigger } from 'gsap/ScrollTrigger';

7

import Nav from './nav';

8

import gsap from 'gsap';

9

10

export default function index() {

11

const header = useRef(null);

12

const [isActive, setIsActive] = useState(false);

13

const pathname = usePathname();

14

const button = useRef(null);

15

16

useEffect( () => {

17

if(isActive) setIsActive(false)

18

}, [pathname])

19

20

useLayoutEffect( () => {

21

gsap.registerPlugin(ScrollTrigger)

22

gsap.to(button.current, {

23

scrollTrigger: {

24

trigger: document.documentElement,

25

start: 0,

26

end: window.innerHeight,

27

onLeave: () => {gsap.to(button.current, {scale: 1, duration: 0.25, ease: "power1.out"})},

28

onEnterBack: () => {gsap.to(button.current, {scale: 0, duration: 0.25, ease: "power1.out"})}

29

}

30

})

31

}, [])

32

33

return (

34

<>

35

<div ref={header} className={styles.header}>

36

<div className={styles.logo}>

37

<p className={styles.copyright}>©</p>

38

<div className={styles.name}>

39

<p className={styles.codeBy}>Code by</p>

40

<p className={styles.dennis}>Dennis</p>

41

<p className={styles.snellenberg}>Snellenberg</p>

42

</div>

43

</div>

44

<div className={styles.nav}>

45

<div className={styles.el}>

46

<a>Work</a>

47

<div className={styles.indicator}></div>

48

</div>

49

<div className={styles.el}>

50

<a>About</a>

51

<div className={styles.indicator}></div>

52

</div>

53

<div className={styles.el}>

54

<a>Contact</a>

55

<div className={styles.indicator}></div>

56

</div>

57

</div>

58

</div>

59

<div ref={button} className={styles.headerButtonContainer}>

60

<div onClick={() => {setIsActive(!isActive)}} className={`${styles.button}`}>

61

<div className={`${styles.burger} ${isActive ? styles.burgerActive : ""}`}></div>

62

</div>

63

</div>

64

<AnimatePresence mode="wait">

65

{isActive && <Nav />}

66

</AnimatePresence>

67

</>

68

)

69

}

A couple notes about the code:

  • Line 20: The burger menu is initially set at scale(0) and then it is animated to scale(1) after 100vh of scroll.
  • Line 41-56 (CSS) The logo is animated using with simple css animations.

We should have something like this

Description Component

The description is 2 simple paragraphs with a button.

  • The button will have a slight parallax that we can easily make with Locomotive Scroll.
  • The first paragraph is split in words and is animated using a mask an Framer Motion.

Descript...

Descript...

1

import styles from './style.module.scss';

2

import { useInView, motion } from 'framer-motion';

3

import { useRef } from 'react';

4

import { slideUp, opacity } from './animation';

5

export default function index() {

6

7

const phrase = "Helping brands to stand out in the digital era. Together we will set the new status quo. No nonsense, always on the cutting edge.";

8

const description = useRef(null);

9

const isInView = useInView(description)

10

return (

11

<div ref={description} className={styles.description}>

12

<div className={styles.body}>

13

<p>

14

{

15

phrase.split(" ").map( (word, index) => {

16

return <span className={styles.mask}><motion.span variants={slideUp} custom={index} animate={isInView ? "open" : "closed"} key={index}>{word}</motion.span></span>

17

})

18

}

19

</p>

20

<motion.p variants={opacity} animate={isInView ? "open" : "closed"}>The combination of my passion for design, code & interaction positions me in a unique place in the web design world.</motion.p>

21

<div data-scroll data-scroll-speed={0.1}>

22

<div className={styles.button}>

23

<p>About me</p>

24

</div>

25

</div>

26

</div>

27

</div>

28

)

29

}

A couple notes about the code:

  • Line 16: The phrase is split into words and then the span are animated with Framer Motion when the paragraph comes into the view using the useInView Hook.
  • Line 21: A slight parallax is added to the button with Locomotive Scroll and data-scroll-speed.

The animations are defined in an external file:

Description/anim.js

1

export const slideUp = {

2

initial: {

3

y: "100%"

4

},

5

open: (i) => ({

6

y: "0%",

7

transition: {duration: 0.5, delay: 0.01 * i}

8

}),

9

closed: {

10

y: "100%",

11

transition: {duration: 0.5}

12

}

13

}

14

15

export const opacity = {

16

initial: {

17

opacity: 0

18

},

19

open: {

20

opacity: 1,

21

transition: {duration: 0.5}

22

},

23

closed: {

24

opacity: 0,

25

transition: {duration: 0.5}

26

}

27

}

We should have something like this

A couple notes about the code:

  • Line 7: The letter by letter animation is created by using the index of each letter as delay.

We should have something like this

Sliding Images Component

For the sliding images, we will use Framer Motion to create the double horizontal slider and animated the circle.

  • useScroll: We track the progress of the window starting when ["start end"] the top of the container hits the bottom of the window until ["end start"] the end of the container hits and the top of the window.
  • useTransform: we use the input of one value to output another value.

SlidingI...

SlidingI...

1

import { useRef } from 'react';

2

import { useScroll, useTransform, motion } from 'framer-motion';

3

import styles from './style.module.scss';

4

import Image from 'next/image';

5

6

const slider1 = [

7

{

8

color: "#e3e5e7",

9

src: "c2.jpg"

10

},

11

{

12

color: "#d6d7dc",

13

src: "decimal.jpg"

14

},

15

{

16

color: "#e3e3e3",

17

src: "funny.jpg"

18

},

19

{

20

color: "#21242b",

21

src: "google.jpg"

22

}

23

]

24

25

const slider2 = [

26

{

27

color: "#d4e3ec",

28

src: "maven.jpg"

29

},

30

{

31

color: "#e5e0e1",

32

src: "panda.jpg"

33

},

34

{

35

color: "#d7d4cf",

36

src: "powell.jpg"

37

},

38

{

39

color: "#e1dad6",

40

src: "wix.jpg"

41

}

42

]

43

44

export default function index() {

45

46

const container = useRef(null);

47

const { scrollYProgress } = useScroll({

48

target: container,

49

offset: ["start end", "end start"]

50

})

51

52

const x1 = useTransform(scrollYProgress, [0, 1], [0, 150])

53

const x2 = useTransform(scrollYProgress, [0, 1], [0, -150])

54

const height = useTransform(scrollYProgress, [0, 0.9], [50, 0])

55

56

return (

57

<div ref={container} className={styles.slidingImages}>

58

<motion.div style={{x: x1}} className={styles.slider}>

59

{

60

slider1.map( (project, index) => {

61

return <div className={styles.project} style={{backgroundColor: project.color}} >

62

<div key={index} className={styles.imageContainer}>

63

<Image

64

fill={true}

65

alt={"image"}

66

src={`/images/${project.src}`}/>

67

</div>

68

</div>

69

})

70

}

71

</motion.div>

72

<motion.div style={{x: x2}} className={styles.slider}>

73

{

74

slider2.map( (project, index) => {

75

return <div className={styles.project} style={{backgroundColor: project.color}} >

76

<div key={index} className={styles.imageContainer}>

77

<Image

78

fill={true}

79

alt={"image"}

80

src={`/images/${project.src}`}/>

81

</div>

82

</div>

83

})

84

}

85

</motion.div>

86

<motion.div style={{height}} className={styles.circleContainer}>

87

<div className={styles.circle}></div>

88

</motion.div>

89

</div>

90

)

91

}

A couple notes about the code:

  • Line 47: The progress of the scroll is tracked starting from the container.
  • Line 58, 72: The images are translated by 150 and -150 depending on the scroll value.
  • Line 86: The height of the circle is modified depending on the scroll value.

We should have something like this

Contact Component

The footer has 3 parallax animations:

  • The scroll is tracked from ["start end"] the moment the top of the container touches the end of the window until the end of the container touches the end of the window.
  • They are created using the scroll value and inputing it inside the useTransform from Framer Motion.

Contact/...

Contact/...

1

import styles from './style.module.scss';

2

import Image from 'next/image';

3

import { useRef } from 'react';

4

import { useScroll, motion, useTransform, useSpring } from 'framer-motion';

5

import Magnetic from '../common/Magnetic';

6

7

export default function index() {

8

const container = useRef(null);

9

const { scrollYProgress } = useScroll({

10

target: container,

11

offset: ["start end", "end end"]

12

})

13

const x = useTransform(scrollYProgress, [0, 1], [0, 100])

14

const y = useTransform(scrollYProgress, [0, 1], [-500, 0])

15

const rotate = useTransform(scrollYProgress, [0, 1], [120, 90])

16

return (

17

<motion.div style={{y}} ref={container} className={styles.contact}>

18

<div className={styles.body}>

19

<div className={styles.title}>

20

<span>

21

<div className={styles.imageContainer}>

22

<Image

23

fill={true}

24

alt={"image"}

25

src={`/images/background.jpg`}

26

/>

27

</div>

28

<h2>Let's work</h2>

29

</span>

30

<h2>together</h2>

31

<motion.div style={{x}} className={styles.buttonContainer}>

32

<div backgroundColor={"#334BD3"} className={styles.button}>

33

<p>Get in touch</p>

34

</div>

35

</motion.div>

36

<motion.svg style={{rotate, scale: 2}} width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">

37

<path d="M8 8.5C8.27614 8.5 8.5 8.27614 8.5 8L8.5 3.5C8.5 3.22386 8.27614 3 8 3C7.72386 3 7.5 3.22386 7.5 3.5V7.5H3.5C3.22386 7.5 3 7.72386 3 8C3 8.27614 3.22386 8.5 3.5 8.5L8 8.5ZM0.646447 1.35355L7.64645 8.35355L8.35355 7.64645L1.35355 0.646447L0.646447 1.35355Z" fill="white"/>

38

</motion.svg>

39

</div>

40

<div className={styles.nav}>

41

<div className={styles.button}>

42

<p>info@dennissnellenberg.com</p>

43

</div>

44

<div className={styles.button}>

45

<p>+31 6 27 84 74 30</p>

46

</div>

47

</div>

48

<div className={styles.info}>

49

<div>

50

<span>

51

<h3>Version</h3>

52

<p>2022 © Edition</p>

53

</span>

54

<span>

55

<h3>Version</h3>

56

<p>11:49 PM GMT+2</p>

57

</span>

58

</div>

59

<div>

60

<span>

61

<h3>socials</h3>

62

<Magnetic>

63

<p>Awwwards</p>

64

</Magnetic>

65

</span>

66

<Magnetic>

67

<p>Instagram</p>

68

</Magnetic>

69

<Magnetic>

70

<p>Dribbble</p>

71

</Magnetic>

72

<Magnetic>

73

<p>Linkedin</p>

74

</Magnetic>

75

</div>

76

</div>

77

</div>

78

</motion.div>

79

)

80

}

We should have something like this

Preloader Component

For the preloader, we actually don't need to check for any data. So it's more of an introduction page than a loader.

The first step for the preloader to work is to create a state with a isLoading value. That way, we can control when the loader should disappear.

page.js

1

...

2

export default function Home() {

3

4

const [isLoading, setIsLoading] = useState(true);

5

6

useEffect( () => {

7

(

8

async () => {

9

const LocomotiveScroll = (await import('locomotive-scroll')).default

10

const locomotiveScroll = new LocomotiveScroll();

11

12

setTimeout( () => {

13

setIsLoading(false);

14

document.body.style.cursor = 'default'

15

window.scrollTo(0,0);

16

}, 2000)

17

}

18

)()

19

}, [])

20

21

return (

22

<main>

23

<AnimatePresence mode='wait'>

24

{isLoading && <Preloader />}

25

</AnimatePresence>

26

...

27

Preloade...

Preloade...

1

'use client';

2

import styles from './style.module.scss';

3

import { useEffect, useState } from 'react';

4

import { motion } from 'framer-motion';

5

import { opacity, slideUp } from './anim';

6

7

const words = ["Hello", "Bonjour", "Ciao", "Olà", "やあ", "Hallå", "Guten tag", "Hallo"]

8

9

export default function Index() {

10

const [index, setIndex] = useState(0);

11

const [dimension, setDimension] = useState({width: 0, height:0});

12

13

useEffect( () => {

14

setDimension({width: window.innerWidth, height: window.innerHeight})

15

}, [])

16

17

useEffect( () => {

18

if(index == words.length - 1) return;

19

setTimeout( () => {

20

setIndex(index + 1)

21

}, index == 0 ? 1000 : 150)

22

}, [index])

23

24

const initialPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${dimension.height} Q${dimension.width/2} ${dimension.height + 300} 0 ${dimension.height} L0 0`

25

const targetPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${dimension.height} Q${dimension.width/2} ${dimension.height} 0 ${dimension.height} L0 0`

26

27

const curve = {

28

initial: {

29

d: initialPath,

30

transition: {duration: 0.7, ease: [0.76, 0, 0.24, 1]}

31

},

32

exit: {

33

d: targetPath,

34

transition: {duration: 0.7, ease: [0.76, 0, 0.24, 1], delay: 0.3}

35

}

36

}

37

38

return (

39

<motion.div variants={slideUp} initial="initial" exit="exit" className={styles.introduction}>

40

{dimension.width > 0 &&

41

<>

42

<motion.p variants={opacity} initial="initial" animate="enter"><span></span>{words[index]}</motion.p>

43

<svg>

44

<motion.path variants={curve} initial="initial" exit="exit"></motion.path>

45

</svg>

46

</>

47

}

48

</motion.div>

49

)

50

}

All the animations are also defined in an external file:

Preloader/anim.js

1

export const opacity = {

2

initial: {

3

opacity: 0

4

},

5

enter: {

6

opacity: 0.75,

7

transition: {duration: 1, delay: 0.2}

8

},

9

}

10

11

export const slideUp = {

12

initial: {

13

top: 0

14

},

15

exit: {

16

top: "-100vh",

17

transition: {duration: 0.8, ease: [0.76, 0, 0.24, 1], delay: 0.2}

18

}

19

}

To add extra polishing, we can also add a simple slide up animation on the Landing page to make everything more smooth.

We should have something like this

Magnetic Component

Almost all clickable elements of this website have a magnetic effect. What we can do is create a re-usable component that will warp all the elements with want to become magnetic.

We can then wrap all the clickable elements we'd like to be magnetic.

Magnetic/index.jsx

1

import React, { useEffect, useRef } from 'react'

2

import gsap from 'gsap';

3

4

export default function index({children}) {

5

const magnetic = useRef(null);

6

7

useEffect( () => {

8

const xTo = gsap.quickTo(magnetic.current, "x", {duration: 1, ease: "elastic.out(1, 0.3)"})

9

const yTo = gsap.quickTo(magnetic.current, "y", {duration: 1, ease: "elastic.out(1, 0.3)"})

10

11

magnetic.current.addEventListener("mousemove", (e) => {

12

const { clientX, clientY } = e;

13

const {height, width, left, top} = magnetic.current.getBoundingClientRect();

14

const x = clientX - (left + width/2)

15

const y = clientY - (top + height/2)

16

xTo(x * 0.35);

17

yTo(y * 0.35)

18

})

19

magnetic.current.addEventListener("mouseleave", (e) => {

20

xTo(0);

21

yTo(0)

22

})

23

}, [])

24

25

return (

26

React.cloneElement(children, {ref:magnetic})

27

)

28

}

Here we add a ref to the children and add an event listener that moves the element along the mouse. When the mouse leaves the element, it resets back to 0 with an elastic ease.

We should have something like this

Button Component

After creating the Magnetic component, we can now create a re-usable button component that will be used accross the site.

We can then replace all the buttons with this re-usable component.

Rounded/...

Rounded/...

1

import React from 'react'

2

import { useEffect, useRef } from 'react';

3

import styles from './style.module.scss';

4

import gsap from 'gsap';

5

import Magnetic from '../Magnetic';

6

7

export default function index({children, backgroundColor="#455CE9", ...attributes}) {

8

9

const circle = useRef(null);

10

let timeline = useRef(null);

11

let timeoutId = null;

12

useEffect( () => {

13

timeline.current = gsap.timeline({paused: true})

14

timeline.current

15

.to(circle.current, {top: "-25%", width: "150%", duration: 0.4, ease: "power3.in"}, "enter")

16

.to(circle.current, {top: "-150%", width: "125%", duration: 0.25}, "exit")

17

}, [])

18

19

const manageMouseEnter = () => {

20

if(timeoutId) clearTimeout(timeoutId)

21

timeline.current.tweenFromTo('enter', 'exit');

22

}

23

24

const manageMouseLeave = () => {

25

timeoutId = setTimeout( () => {

26

timeline.current.play();

27

}, 300)

28

}

29

30

return (

31

<Magnetic>

32

<div className={styles.roundedButton} style={{overflow: "hidden"}} onMouseEnter={() => {manageMouseEnter()}} onMouseLeave={() => {manageMouseLeave()}} {...attributes}>

33

{

34

children

35

}

36

<div ref={circle} style={{backgroundColor}} className={styles.circle}></div>

37

</div>

38

</Magnetic>

39

)

40

}

We should have something like this

We should have something like this:

Wrapping up

We're offically done with this animation!

That was it for this animation! Path commands are quite hard to use at first, but when you get used to it, it's possible to create really animations. Hope you learned something!

-Oli

Related Animations

image

June 11, 2023

Project Gallery Mouse Hover

An awwwards winning website tutorial with a project gallery featuring a hover animation using Nextjs, GSAP and Framer Motion. Inspired by: https://dennissnellenberg.com/

image

May 28, 2023

Infinite Text Move On Scroll

An infinite text moving animation with scroll interaction using React and Next.js. Picture by Eric Asamoah.

image

May 13, 2023

Image slide project gallery

A project gallery animation featuring an image slide effect using the width auto animation of Framer Motion. Made with React and Next.js. Inspired by https://locomotive.ca/