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/
Olivier Larose
July 24, 2023
/
Intermediate
/
Long
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 TutorialLet'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.
Here's a list of all the libraries we need for this landing page:
npm i sass
.npm i framer-motion
.npm i gsap
.npm i npm install locomotive-scroll@beta
.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')).default10
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
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:
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.
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:
scale(0)
and then it is animated to scale(1)
after 100vh
of scroll.The description is 2 simple paragraphs with a button.
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:
useInView
Hook.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: 018
},19
open: {20
opacity: 1,21
transition: {duration: 0.5}22
},23
closed: {24
opacity: 0,25
transition: {duration: 0.5}26
}27
}
A couple notes about the code:
For the sliding images, we will use Framer Motion to create the double horizontal slider and animated the circle.
["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.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
<Image64
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
<Image78
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:
The footer has 3 parallax animations:
["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.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
<Image23
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
}
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')).default10
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: 04
},5
enter: {6
opacity: 0.75,7
transition: {duration: 1, delay: 0.2}8
},9
}10
11
export const slideUp = {12
initial: {13
top: 014
},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.
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.
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.current15
.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
children35
}36
<div ref={circle} style={{backgroundColor}} className={styles.circle}></div>37
</div>38
</Magnetic>39
)40
}
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