DEV Community

Karan Saini
Karan Saini

Posted on

Creating a Multi-Level Sidebar Animation with Tailwind and Framer Motion in React

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;




Enter fullscreen mode Exit fullscreen mode



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


Enter fullscreen mode Exit fullscreen mode


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>


Enter fullscreen mode Exit fullscreen mode


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


Enter fullscreen mode Exit fullscreen mode


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>


Enter fullscreen mode Exit fullscreen mode


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


Enter fullscreen mode Exit fullscreen mode


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.

Multi-level navigation demo


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

Top comments (0)