Animated Hamburger Menu with react-spring
Resources
Step 1: Installation
npm install react-spring
# or
yarn add react-spring
Step 2: Header Component
Create a Header component that will contain our state and animation.
We'll pass a function to the useSpring hook and get back our animation styles as well as an api we can use for updating our animation values.
I abstracted some of the configuration values into animationConfig to make it easier to tweak as we will reference these values in several places.
Read more about react-spring-config.
// Header.js
import React, { useState } from 'react'
import { useSpring } from 'react-spring'
const animationConfig = {
mass: 1,
frictionLight: 20,
frictionHeavy: 30,
tension: 575,
delay: 175,
}
const Header = () => {
const [open, toggle] = useState(false)
const [styles, api] = useSpring(() => ({
transformTop: 'translate(-6px, 10px) rotate(0deg)',
transformMiddle: 'translate(-6px, 0px) rotate(0deg)',
transformBottom: 'translate(-6px, -10px) rotate(0deg)',
widthTop: '24px',
widthBottom: '20px',
config: {
mass: animationConfig.mass,
friction: animationConfig.frictionHeavy,
tension: animationConfig.tension,
},
}))
return <header></header>
}
export default Header
Step 3: Creating Our Hamburger Menu
Create a HamburgerMenu component and in it we'll need to import animated from react-spring and extend each div element we're animating.
// HamburgerMenu.js
import React from 'react'
import { animated } from 'react-spring'
const HamburgerMenu = () => {
return (
<button>
<animated.div />
<animated.div />
<animated.div />
</button>
)
}
export default HamburgerMenu
Give our button an onClick event.
We'll create a separate handleClick function to handle our click event where we can use the start method and update our animation based on the current open state.
useSpring allows us to chain animations in an array while being able to update our values and partially update config at each step.
For both opening and closing we're passing clamp: true to the config in order to ease into the three bars coming together.
On the second half of our chained animations we pass clamp: false which will allow our spring animation to be springy when animating to its final position.
We'll also add a slight delay to the start of the second half of our animations in order to let our menu lines rest overtop one another before springing out.
At the end of our handleClick function we'll update the state with our toggle method.
Make sure to reference the correct styles for each of our animated.div.
// HamburgerMenu.js
import React from 'react'
import { animated } from 'react-spring'
const HamburgerMenu = ({ open, toggle, api, styles, animationConfig }) => {
const handleClick = () => {
api.start({
to: open
? [
{
transformTop: 'translate(-6px, 18.5px) rotate(0deg)',
transformMiddle: 'translate(-6px, 0px) rotate(0deg)',
transformBottom:
'translate(-6px, -18.5px) rotate(0deg)',
widthTop: '28px',
widthBottom: '28px',
config: { clamp: true },
},
{
transformTop: 'translate(-6px, 10px) rotate(0deg)',
transformMiddle: 'translate(-6px, 0px) rotate(0deg)',
transformBottom:
'translate(-6px, -10px) rotate(0deg)',
widthTop: '24px',
widthBottom: '20px',
config: {
clamp: false,
friction: animationConfig.frictionLight,
tension: animationConfig.tension,
},
delay: animationConfig.delay,
},
]
: [
{
transformTop: 'translate(-6px, 18.5px) rotate(0deg)',
transformMiddle: 'translate(-6px, 0px) rotate(0deg)',
transformBottom:
'translate(-6px, -18.5px) rotate(0deg)',
widthTop: '28px',
widthBottom: '28px',
config: { clamp: true },
},
{
transformTop: 'translate(-6px, 18.5px) rotate(45deg)',
transformMiddle: 'translate(-6px, 0px) rotate(45deg)',
transformBottom:
'translate(-6px, -18.5px) rotate(-45deg)',
widthTop: '28px',
widthBottom: '28px',
config: {
clamp: false,
friction: animationConfig.frictionLight,
tension: animationConfig.tension,
},
delay: animationConfig.delay,
},
],
})
toggle((prev) => !prev)
}
return (
<button onClick={handleClick}>
<animated.div
style={{
transform: styles.transformTop,
width: styles.widthTop,
}}
/>
<animated.div
style={{
transform: styles.transformMiddle,
}}
/>
<animated.div
style={{
transform: styles.transformBottom,
width: styles.widthBottom,
}}
/>
</button>
)
}
export default HamburgerMenu
Step 4: Mobile Navigation
Lets go ahead and display our menu when the user clicks the hamburger button.
First create a new component called MobileNav and import the necessary dependencies.
Here we're using useTransition from react-spring in order to animate our elements when they mount and unmount from the page.
I also went ahead and imported a couple icons from react-icons but its up to you what content you want to use.
When we return our UI from our component we pass a callBack to the transition function that contains our styles and our visible item which is our open state that we passed into the useTransition hook.
Now when we click our hamburger menu open it will trigger our transition.
open = true
- Mounts the page, starting at
fromstyles and ending atenterstyles.
open = false
- Moving from
enterstyles toleavestyles and un-mounts the page.
// MobileNav.js
import { useEffect } from 'react'
import { useTransition, animated } from 'react-spring'
import { IoLogoInstagram, IoLogoGithub } from 'react-icons/io5'
const headings = ['Home', 'Blog', 'About', 'Contact']
const MobileNav = ({ open }) => {
useEffect(() => {
if (open) {
document.body.style.overflowY = 'hidden'
return
}
document.body.style.overflowY = 'auto'
}, [open])
const transition = useTransition(open, {
from: {
opacity: 0,
transformMain: 'translateY(40px)',
transformFoot: 'translateY(200px)',
},
enter: {
opacity: 1,
transformMain: 'translateY(0px)',
transformFoot: 'translateY(0px)',
},
leave: {
opacity: 0,
transformMain: 'translateY(40px)',
transformFoot: 'translateY(200px)',
},
})
return transition(({ opacity, transformMain, transformFoot }, visible) => {
return visible ? (
<animated.nav style={{ opacity }}>
<div>
<animated.ul style={{ transform: transformMain }}>
{headings.map((heading) => (
<li key={heading}>{heading}</li>
))}
</animated.ul>
<animated.div style={{ transform: transformFoot }}>
<IoLogoInstagram />
<IoLogoGithub />
</animated.div>
</div>
</animated.nav>
) : null
})
}
export default MobileNav
Rendering Our Components
Now we can render our HamburgerMenu and MobileNav components in our parent Header component and pass each component the necessary props.
Render the MobileNav outside the element and wrap all our elements in a Fragment so we don't cause the element to grow to the full size of the page when our MobileNav mounts on screen.
// Header.js
return (
<>
<header>
<HamburgerMenu
open={open}
toggle={toggle}
styles={styles}
api={api}
animationConfig={animationConfig}
/>
</header>
<MobileNav open={open} />
</>
)
Styling
We definitely need some styling so lets go ahead and do that by adding some classes to our elements and defining the styles.
// Header
return (
<>
<header className="header">
<HamburgerMenu
open={open}
toggle={toggle}
styles={styles}
api={api}
animationConfig={animationConfig}
/>
</header>
<MobileNav open={open} />
</>
)
// HamburgerMenu.js
return (
<button className="btn" onClick={handleClick}>
<animated.div
style={{ transform: styles.transformTop, width: styles.widthTop }}
className="menu-line"
/>
<animated.div
style={{ transform: styles.transformMiddle }}
className="menu-line"
/>
<animated.div
style={{
transform: styles.transformBottom,
width: styles.widthBottom,
}}
className="menu-line"
/>
</button>
)
// MobileNav.js
return visible ? (
<animated.nav style={{ opacity }} className="mobile-nav">
<div className="content-wrapper">
<animated.ul style={{ transform: transformMain }} className="list">
{headings.map((heading) => (
<li className="list-item" key={heading}>
{heading}
</li>
))}
</animated.ul>
<animated.div
className="icon-wrapper"
style={{ transform: transformFoot }}
>
<IoLogoInstagram className="icon" />
<IoLogoGithub className="icon" />
</animated.div>
</div>
</animated.nav>
) : null
/* styles.css */
.header {
position: relative;
display: flex;
justify-content: flex-end;
max-width: 48rem;
margin: 0 auto;
padding: 2rem 2rem 4rem;
z-index: 20;
}
.btn {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
width: 40px;
height: 40px;
padding: 0;
border: none;
overflow: hidden;
}
.menu-line {
height: 3px;
background-color: #000;
border-radius: 2px;
}
.menu-line:nth-of-type(2) {
width: 28px;
}
.mobile-nav {
position: fixed;
display: flex;
flex-direction: column;
box-sizing: border-box;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width: 100%;
padding: 2rem 2rem 6rem 2rem;
z-index: 10;
}
.content-wrapper {
margin-top: 3rem;
}
.list {
list-style: none;
padding: 0;
}
.list-item {
font-size: 2.25rem;
font-weight: bold;
font-family: sans-serif;
margin: 1rem 0;
text-align: center;
}
.icon-wrapper {
display: flex;
justify-content: center;
margin-top: 3rem;
}
.icon {
width: 2.5rem;
height: 2.5rem;
}
.icon:nth-of-type(1) {
margin-right: 1.5rem;
}
Conclusion
In this article we walked through how to create a hamburger menu with react-spring. react-spring is one of my favorite animation libraries and can be used in so many interesting ways. I suggest you check out some of the examples shown in the react-spring documentation to get inspired.
Hopefully you found this helpful!