Creating a Multi-Level Sidebar Animation with Tailwind and Framer Motion in React
Tags: animation, react, framermotion, webdev
Published: 2023-12-16 23:57:10

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.
<br>

## 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.<br><br>

## <br> 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. 
<br><br>

## 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. <br>
```js
"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;


```
<br>
I have also created a mock navigation data object that can be imported in the Sidebar file:<br>

```js
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)",
      },
    ],
  },
];
```

<br>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: <br>

```js
<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>
```

<br>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. <br>

```js
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;
  };
```

<br>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. <br>

```js
{/* 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>
```

<br>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. <br>


```js
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" },
  }),
};
```
 
<br>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. <br>


![Multi-level navigation demo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/246r2n83odeghvlr060v.gif)


<br>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! 📖