Creating a Multi-Level Sidebar Animation with Tailwind and Framer Motion in React
Karan Saini
Posted on December 16, 2023
Hello folks!
I am new to the dev.to community, and this is my first post. Hopefully it helps a few people out there starting to work with animation in React.js projects.
In this tutorial, we'll dive into the intricacies of building a responsive sidebar navigation component in React. What's more? We'll add the finesse of smooth animations, thanks to Tailwind CSS for styling and Framer Motion for the magic of motion. I will primarily use the different animation states on the animated components and combine them with the AnimatePresence
component from framer-motion to make sure the components that are entering or exiting the DOM are animated. So let's get into it.
Pre-requisites
Before diving into the tutorial, let's ensure you have the necessary tools and knowledge in your arsenal:
Basic Understanding of React
- Familiarity with React fundamentals such as components, state management with hooks (like useState), and handling events.
Knowledge of JavaScript
- A solid grasp of JavaScript, including ES6+ features like arrow functions, array methods (such as map and slice), and object destructuring.
Understanding of Tailwind CSS
- Some exposure to Tailwind CSS for styling components in React. Basic knowledge of Tailwind's utility classes will be beneficial.
Introduction to Framer Motion
- While not mandatory, prior exposure to Framer Motion's animation library can be advantageous. Understanding the concepts of variants and motion components will ease your journey.
Setup
There is no setup needed since I will just be sharing the React component itself and it can be plugged in to another component.
Unraveling the Code
Let's get to the code now. I have created a Sidebar.tsx
file and the code has been shared below. Further below, I will go over the different sections of the file.
"use client";
import React, { useState } from "react";
import { navigationData } from "./index";
import { FaChevronRight, FaChevronLeft } from "react-icons/fa";
import classnames from "classnames";
import { AnimatePresence, Variants, motion } from "framer-motion";
export const variants: Variants = {
"in-view": { x: "0px", transition: { type: "tween", ease: "easeOut" } },
"out-of-view": (index: number) => ({
x: index > 0 ? "250px" : "-250px",
transition: { type: "tween", ease: "easeOut" },
}),
};
function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const toggleMenu = () => {
setIsOpen(!isOpen);
};
const goToNextLevel = (item: any) => {
if (!item.links) {
return;
}
setSelectedItems([...selectedItems, item.id]);
};
const goBack = () => {
const selectedItemsClone = [...selectedItems];
selectedItemsClone.pop();
setSelectedItems([...selectedItemsClone]);
};
const getNavItems = (selectedItems: string[]) => {
let result: any[] = [];
let links: any[] = [...navigationData];
let itr = 0;
if (selectedItems.length === 0) {
return navigationData;
}
// We will run the loop until we reach the correct level
while (itr < selectedItems.length) {
let selectedLink: any;
// Finding the selected item for this level
for (let i = 0; i < links.length; i++) {
if (links[i].id === selectedItems[itr]) {
selectedLink = links[i];
// We keep a track of the next level links
if (selectedLink.links) {
result = [...selectedLink.links];
}
break;
}
}
links = [...result];
itr++;
}
return result;
};
return (
<aside
className={classnames(
"fixed top-0 bottom-0 w-[250px] p-4 bg-black transition-all duration-300 overflow-hidden",
{
"left-0": isOpen,
"-left-[200px]": !isOpen,
}
)}
>
<div className="text-white flex flex-col relative">
<button
className="[&>svg]:text-[32px] absolute right-0 top-0"
onClick={toggleMenu}
>
{isOpen ? <FaChevronLeft /> : <FaChevronRight />}
</button>
<nav className="mt-24 relative">
<motion.ul
variants={variants}
initial="in-view"
animate={selectedItems.length > 0 ? "out-of-view" : "in-view"}
custom={selectedItems.length > 0 ? -1 : 0}
className="w-full duration-200 absolute top-0"
>
{/* First level items */}
{navigationData?.map((item: any) => {
return (
<li key={item.id} className="px-4 py-2">
<button
onClick={() => goToNextLevel(item)}
className="flex flex-row items-center w-full"
>
<span className="pr-2">{item.label}</span>
{item.links && <FaChevronRight />}
</button>
</li>
);
})}
</motion.ul>
{/* Subsequent levels */}
<AnimatePresence>
{selectedItems.length > 0 &&
selectedItems.map((id, index) => {
return (
<motion.ul
key={id}
variants={variants}
initial="out-of-view"
animate={
index + 1 === selectedItems.length
? "in-view"
: "out-of-view"
}
exit="out-of-view"
custom={index + 1 === selectedItems.length ? 1 : -1}
className="w-full duration-200 absolute top-0"
>
<li className="pb-4">
<button className="flex items-center" onClick={goBack}>
<FaChevronLeft /> <span className=" pl-2">Back</span>
</button>
</li>
{getNavItems(selectedItems.slice(0, index + 1))?.map(
(item: any) => {
return (
<li key={item.id} className="px-4 py-2">
<button
onClick={() => goToNextLevel(item)}
className="flex flex-row items-center w-full"
>
<span className="pr-2">{item.label}</span>
{item.links && <FaChevronRight />}
</button>
</li>
);
}
)}
</motion.ul>
);
})}
</AnimatePresence>
</nav>
</div>
</aside>
);
}
export default Sidebar;
I have also created a mock navigation data object that can be imported in the Sidebar file:
import { v4 } from "uuid";
export const navigationData = [
{
id: v4(),
label: "Link 1 (level 1)",
links: [
{
id: v4(),
label: "Link 1 (level 2)",
links: [
{
id: v4(),
label: "Link 1 (level 3)",
},
{
id: v4(),
label: "Link 2 (level 3)",
},
{
id: v4(),
label: "Link 3 (level 3)",
},
],
},
{
id: v4(),
label: "Link 2 (level 2)",
},
{
id: v4(),
label: "Link 3 (level 2)",
},
],
},
{
id: v4(),
label: "Link 2 (level 1)",
links: [
{
id: v4(),
label: "Link 1 (level 2)",
links: [
{
id: v4(),
label: "Link 1 (level 3)",
},
{
id: v4(),
label: "Link 2 (level 3)",
},
{
id: v4(),
label: "Link 3 (level 3)",
},
],
},
{
id: v4(),
label: "Link 2 (level 2)",
},
{
id: v4(),
label: "Link 3 (level 2)",
},
],
},
{
id: v4(),
label: "Link 3 (level 1)",
links: [
{
id: v4(),
label: "Link 1 (level 2)",
links: [
{
id: v4(),
label: "Link 1 (level 3)",
},
{
id: v4(),
label: "Link 2 (level 3)",
},
{
id: v4(),
label: "Link 3 (level 3)",
},
],
},
{
id: v4(),
label: "Link 2 (level 2)",
},
{
id: v4(),
label: "Link 3 (level 2)",
},
],
},
];
As you can infer, the navigation extends to 3 levels. The code is agnostic of the degree of nesting though.
The idea here is that we will store a trail of all the links that were clicked by the user in the selectedItems
state variable.
We will start with the first level of navigation which will always be rendered. The code can be seen below:
<motion.ul
variants={variants}
initial="in-view"
animate={selectedItems.length > 0 ? "out-of-view" : "in-view"}
custom={selectedItems.length > 0 ? -1 : 0}
className="w-full duration-200 absolute top-0"
>
{/* First level items */}
{navigationData?.map((item: any) => {
return (
<li key={item.id} className="px-4 py-2">
<button
onClick={() => goToNextLevel(item)}
className="flex flex-row items-center w-full"
>
<span className="pr-2">{item.label}</span>
{item.links && <FaChevronRight />}
</button>
</li>
);
})}
</motion.ul>
When a user clicks on one of the first level links, we will calculate what links to show for the next level using the function getNavItems
. It accepts the selectedItems
as a parameter and then loops over the mock navigation data to find the next level links.
const getNavItems = (selectedItems: string[]) => {
let result: any[] = [];
let links: any[] = [...navigationData];
let itr = 0;
if (selectedItems.length === 0) {
return navigationData;
}
// We will run the loop until we reach the correct level
while (itr < selectedItems.length) {
let selectedLink: any;
// Finding the selected item for this level
for (let i = 0; i < links.length; i++) {
if (links[i].id === selectedItems[itr]) {
selectedLink = links[i];
// We keep a track of the next level links
if (selectedLink.links) {
result = [...selectedLink.links];
}
break;
}
}
links = [...result];
itr++;
}
return result;
};
All the subsequent levels apart from the first are rendered conditionally based on the selectedItems
's value. Now let's get to the animation.
Our aim is that when user clicks on any link which has nested links, then the list should slide to the left, and the next level links should slide in from right. And if user clicks on the "Back" button, the animation should be in the opposite direction.
{/* Subsequent levels */}
<AnimatePresence>
{selectedItems.length > 0 &&
selectedItems.map((id, index) => {
return (
<motion.ul
key={id}
variants={variants}
initial="out-of-view"
animate={
index + 1 === selectedItems.length
? "in-view"
: "out-of-view"
}
exit="out-of-view"
custom={index + 1 === selectedItems.length ? 1 : -1}
className="w-full duration-200 absolute top-0"
>
<li className="pb-4">
<button className="flex items-center" onClick={goBack}>
<FaChevronLeft /> <span className=" pl-2">Back</span>
</button>
</li>
{getNavItems(selectedItems.slice(0, index + 1))?.map(
(item: any) => {
return (
<li key={item.id} className="px-4 py-2">
<button
onClick={() => goToNextLevel(item)}
className="flex flex-row items-center w-full"
>
<span className="pr-2">{item.label}</span>
{item.links && <FaChevronRight />}
</button>
</li>
);
}
)}
</motion.ul>
);
})}
</AnimatePresence>
If you notice, instead of a regular ul
element, I am using motion.ul
. The motion
component which comes from framer-motion
adds animation capabilities to the element which in this case is ul
.
We have passed a variants
prop to the motion component, through which we are telling the element what possible animation states exists. The object that we passed to variants
is a mapping of the animation state to the properties that are changing, which is something similar to keyframes animation in css.
We are modifying the value of x
which will animate the translateX
property of the ul
element. There is a property called transition
that is passed in the variant below. We can use it to define if you want a tween
based animation or spring
based. In this example, I have used tween
plus I have selected easeOut
as the easing function.
export const variants: Variants = {
"in-view": { x: "0px", transition: { type: "tween", ease: "easeOut" } },
"out-of-view": (index: number) => ({
x: index > 0 ? "250px" : "-250px",
transition: { type: "tween", ease: "easeOut" },
}),
};
Next, we use the animate
prop to tell the element which state it should animate to, based on some logic. The logic is pretty simple and it basically states that if this is the level that the user is on currently then bring it in view.
We also pass the motion component a custom
props. This prop can be used to pass some variables to your variants mappings. In the variants
object above, you can see I am using the
index
variable to decide the direction of the animation.
Now the logic sounds good, but as you can see in the code that we render the nested levels conditionally and we also intend to animate them. To achieve that I have wrapped the subsequent lists inside AnimatePresence
component, which will make sure the lists animate while entering and exiting the DOM.
That's pretty much it. You can now plug the Sidebar
component wherever you want and it should work like the example shared below.
This is of course just one of the many ways to achieve the multi-level animation and it can be modified according to your use case.
I hope this helps someone who is experimenting with framer-motion. Thanks for reading! 📖
Posted on December 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 16, 2023