Let's build a custom animated VueJs sidebar with TailwindCSS

Let's build a custom animated VueJs sidebar with TailwindCSS

ยท

10 min read

Creating a modern, animated sidebar that seamlessly integrates with both Vue 2 and Vue 3, while leveraging the power of local storage to remember its state, is a fantastic way to enhance the user experience of your web application. This guide will walk you through the process, step by step, complete with code snippets and emojis to make the journey more enjoyable and engaging. Let's dive in! ๐Ÿš€

Project Structure

  1. App.vue: This is the main component where we will include our SidebarMenu component.

  2. SidebarMenu.vue: This component contains the logic and template for the sidebar, including its animation and state management.

  3. SidebarMenuNav.vue: A child component of SidebarMenu, responsible for rendering the navigation links dynamically.

Setting Up App.vue

In your App.vue, import the SidebarMenu component and include it in your template. Below is the basic structure of App component.

<script setup>
import SidebarMenu from './components/SidebarMenu.vue';
</script>

<template>
  <div class="flex">
    <SidebarMenu />
    <div class=" flex flex-col flex-1 min-w-0 h-screen overflow-hidden p-5 bg-slate-900 text-white">
      Content Section
    </div>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
</style>

We're using Tailwind CSS for styling, but feel free to use your preferred styling solution. ๐ŸŽจ

Creating SidebarMenu.vue

This component is the heart of our sidebar. It includes the logic for toggling the sidebar, storing its state in local storage, and determining the active state of navigation links.

<template>
  <nav
    :class="toggleSidebar ? 'w-[220px]' : 'w-[76px]'"
    class="relative z-[51] flex-shrink-0 bg-blue-500 h-[100vh] isolate transition-all duration-200">
    <div class="h-full pt-4">
      <div class="flex flex-col justify-between h-full">
        <div class="flex-1 flex flex-col gap-4 overflow-y-auto">
          <div class="text-white h-9">
            <a href="/agent/home"
               class="flex items-center pl-5 mt-[5px] font-bold">
              JMS
            </a>
          </div>
          <SidebarMenuNav :toggle-sidebar="toggleSidebar" />
        </div>
        <ul class="text-white pt-9">
          <li class="group relative text-white">
            <div v-for="(item, index) in menuItems" :key="index">
              <div
                :class="
                  isAnySubItemActive(item.subItems)
                    ? 'bg-[rgba(255,255,255,0.35)] group-hover:!bg-[rgba(255,255,255,0.35)]'
                    : ''
                "
                class="cursor-pointer text-white flex items-center h-12 gap-3 pl-6 bg-[rgba(255,255,255,0.1)] hover:bg-[rgba(255,255,255,0.25)] transition-all duration-200">
                <div v-html="item.icon"></div>
                <span v-if="toggleSidebar" class="whitespace-nowrap">
                  {{ item.title }}
                </span>
              </div>
              <div
                :class="toggleSidebar ? 'left-full' : 'left-[76px]'"
                class="bg-[#3e4454] absolute bottom-0 opacity-0 pointer-events-none flex flex-col shadow-[4px_8px_30px_2px_rgb(0_0_0/60%)] min-w-[230px] transition-all duration-300 ease-out group-hover:opacity-100 group-hover:pointer-events-auto">
                <ul>
                  <li
                    v-for="(subItem, subIndex) in item.subItems"
                    :key="subIndex"
                    class="relative"
                    :class="
                      isActive(subItem.link)
                        ? 'bg-[rgba(255,255,255,0.35)]'
                        : 'hover:bg-[rgba(248,250,252,0.20)]'
                    "
                  >
                    <a
                      :href="subItem.link"
                      class="flex flex-wrap text-sm px-3 py-4 transition-all duration-300 hover:text-white hover:bg-[rgba(248,250,252,0.20)]">
                      <span class="block w-full whitespace-nowrap truncate">
                        {{ subItem.title }}
                      </span>
                    </a>
                  </li>
                </ul>
              </div>
            </div>
          </li>
          <li class="relative h-12 flex items-center justify-end pr-7 cursor-pointer text-white bg-[rgba(255,255,255,0.2)] group/logout"
              @click="handleToggleSidebar">
            <div
              class="transform transition-all duration-300"
              :class="toggleSidebar ? '-rotate-180' : ''"
            >
              <span class="flex items-center">
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  width="16"
                  height="16"
                  fill="currentColor"
                  viewBox="0 0 16 16"
                  class="w-6 h-6"
                >
                  <path
                    d="m8.001 2.666-.94.94 3.72 3.727H2.668v1.333h8.113l-3.72 3.727.94.94 5.334-5.334L8 2.666Z"
                  ></path>
                </svg>
              </span>
            </div>

            <div class="absolute min-w-[88px] -right-[100px] opacity-0 pointer-events-none group-hover/logout:opacity-100 group-hover/logout:pointer-events-auto transition duration-300 text-xs text-center text-white bg-slate-700 shadow-[0_4px_6px_0_rgba(0,0,0,0.25)] rounded p-2">
              <span>Open/Collapse</span>
              <span class="absolute -left-3 w-0 h-0 border-t-[7px] border-t-transparent border-r-[12px] border-r-slate-700 border-b-[7px] border-b-transparent"></span>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </nav>
</template>

<script setup>
import { ref } from 'vue';
import SidebarMenuNav from './SidebarMenuNav.vue';

const toggleSidebar = ref(
  localStorage.getItem('toggleSidebar') === 'true' || false
);
const currentPath = ref(window.location.pathname);
const menuItems = ref([
  {
    id: 1,
    icon: '<span class="flex items-center"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" class="w-6 h-6"><path d="M19.138 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22l-1.91 3.32c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94 0 .31.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58Zm-7.14 2.66a3.61 3.61 0 0 1-3.6-3.6c0-1.98 1.62-3.6 3.6-3.6s3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6Z"></path></svg></span>',
    title: 'Settings',
    link: '',
    subItems: [
      {
        id: 'account',
        title: 'Account',
        sub_text: '',
        link: '#',
        secondaryItems: [],
        secondary_open: false,
        state: true,
        count: 0,
      },
      {
        id: 'billing-information',
        title: 'Billing',
        sub_text: '',
        link: '#',
        secondaryItems: [],
        secondary_open: false,
        state: true,
        count: 0,
      },
    ],
    open: false,
    new: false,
    count: 0,
    manuallyToggled: false,
  },
]);

const handleToggleSidebar = () => {
  toggleSidebar.value = !toggleSidebar.value;

  if (toggleSidebar.value) {
    localStorage.setItem('toggleSidebar', 'true');
  } else {
    localStorage.removeItem('toggleSidebar');
  }
};

const isActive = (route) => {
  return currentPath.value === route;
};

const isAnySubItemActive = (item) => {
  return item.some((subItem) => isActive(subItem.link));
};

window.addEventListener('popstate', () => {
  currentPath.value = window.location.pathname;
});
</script>

Here's a breakdown of its major features and logic:

  1. Responsive Sidebar: The sidebar's width toggles between two sizes (w-[220px] and w-[76px]) based on the toggleSidebar state, providing a responsive design.

  2. Menu Items: The sidebar displays a list of menu items, each containing a title, icon, and potentially sub-items.

  3. Dynamic Classes: Various classes are conditionally applied based on states like toggleSidebar, group-hover, and active routes, facilitating dynamic UI changes like background color shifts and icon rotations.

  4. Local Storage: The component uses local storage to persist the state of the sidebar (toggleSidebar), ensuring the sidebar remains in its last state even after page refresh.

  5. Event Handling: Click events (handleToggleSidebar) toggle the sidebar state and update local storage accordingly. Additionally, the component listens for popstate events to update the current route.

  6. Active Route Highlighting: The sidebar highlights the active route based on the current path (isActive function), ensuring users can easily identify their location within the application.

  7. Sub-Item Handling: Sub-items can be expanded or collapsed, and their active states are also tracked to maintain proper UI feedback.

Now Lets create SidebarMenuNav.vue component

Here's the code below

<template>
  <ul class="gap-3 overflow-y-auto overflow-x-hidden text-white transition-all duration-200 space-y-3">
    <li class="group transition-all duration-200">
      <a
        href="/"
        :class="toggleSidebar ? 'flex flex-row items-start' : ''"
        class="font-medium cursor-pointer relative hover:text-white"
      >
        <span
          :class="[
            !toggleSidebar ? 'rounded-[8px] ml-5 w-8 h-8' : 'pl-5 h-10 w-10',
            isActive('/')
              ? 'bg-[rgba(255,255,255,0.35)]'
              : 'group-hover:bg-[rgba(255,255,255,0.2)]',
          ]"
          class="flex items-center flex-shrink-0 justify-center flex-shrink-0 group-hover:text-white hover:!text-white transition duration-300 relative">
          <span class="flex items-center">
               <svg
                  xmlns="http://www.w3.org/2000/svg"
                  width="18"
                  height="18"
                  fill="currentColor"
                  viewBox="0 0 18 18"
                  class="w-5 h-5"
                >
                  <path
                    d="M7.5 15.375v-4.5h3v4.5h3.75v-6h2.25L9 2.625l-7.5 6.75h2.25v6H7.5Z"
                  ></path>
                </svg>
          </span>
        </span>

        <span
          class="whitespace-nowrap"
          :class="[
            isActive('/') && toggleSidebar
              ? 'bg-[rgba(255,255,255,0.35)] group-hover:!bg-[rgba(255,255,255,0.35)]'
              : '',
            toggleSidebar
              ? 'flex-1 text-xsm transition duration-300 h-10 flex items-center pl-2 group-hover:bg-[rgba(255,255,255,0.2)]'
              : 'block mx-auto mt-1 text-center text-[10px]',
          ]"
        >
          Home
        </span>
      </a>
    </li>
    <li
      v-for="(item, index) in menuItems"
      :key="index"
      class="group transition-all duration-200"
    >
      <div
        :class="toggleSidebar ? 'flex flex-row items-start' : ''"
        class="font-medium cursor-pointer relative"
        @click="toggleAccordion(index)"
      >
        <a
          :href="item.link"
          :class="[
            !toggleSidebar ? 'rounded-[8px] ml-5 w-8 h-8' : 'pl-5 h-10 w-10',
            isAnySubItemActive(item.subItems)
              ? 'bg-[rgba(255,255,255,0.35)]'
              : 'group-hover:bg-[rgba(255,255,255,0.2)]',
          ]"
          class="flex items-center justify-center flex-shrink-0 group-hover:text-white hover:!text-white transition duration-300 relative">
          <div v-html="item.icon"></div>
          <span
            v-if="!toggleSidebar && item.new"
            class="absolute -top-1 -right-4 h-[14px] w-[30px] leading-[11px] rounded bg-red-500 text-[10px] uppercase font-bold text-white flex items-center justify-center">
            New
          </span>

          <span
            v-if="!toggleSidebar && item.count"
            class="absolute top-3 -left-0.5 w-1.5 h-1.5 rounded-full bg-red-500 flex items-center justify-center"></span>
        </a>

        <span
          class="whitespace-nowrap"
          :class="[
            isAnySubItemActive(item.subItems) && toggleSidebar
              ? 'bg-[rgba(255,255,255,0.35)] group-hover:!bg-[rgba(255,255,255,0.35)]'
              : '',
            toggleSidebar
              ? 'flex-1 text-xsm transition duration-300 h-10 flex items-center pl-2 group-hover:bg-[rgba(255,255,255,0.2)]'
              : 'block mx-auto mt-1 text-center text-[10px]',
          ]"
        >
          {{ item.title }}
          <span
            v-if="toggleSidebar && item.new"
            class="w-9 h-[14px] leading-[11px] ml-3 uppercase rounded-full bg-red-500 text-white font-bold text-[10px] flex items-center justify-center">
            New
          </span>
        </span>

        <Transition name="fade-in">
          <span
            v-if="toggleSidebar && item.count"
            class="absolute top-4 right-10 w-1.5 h-1.5 rounded-full bg-red-500 flex items-center justify-center"></span>
        </Transition>
        <Transition name="fade-in">
          <span
            v-if="toggleSidebar && item.subItems.length > 0"
            class="flex items-center"
            ><svg
              xmlns="http://www.w3.org/2000/svg"
              width="16"
              height="16"
              fill="currentColor"
              viewBox="0 0 16 16"
              class="w-6 h-6"
              :class="
                item.open
                  ? 'absolute right-3 top-2 transform duration-200 transition'
                  : 'absolute right-3 top-2 rotate-90 duration-200 transition'
              "
            >
              <path
                d="M4.94 5.53 8 8.583l3.06-3.053.94.94-4 4-4-4 .94-.94Z"
              ></path>
            </svg>
          </span>
        </Transition>
      </div>
      <Transition name="height-slide-in" mode="out-in">
        <div
          v-if="item.subItems.length > 0 && (!toggleSidebar || item.open)"
          :class="`${
            !toggleSidebar
              ? 'bg-[#3e4454] absolute opacity-0 left-[76px] pointer-events-none flex flex-col space-y-2 shadow-[4px_8px_30px_2px_rgb(0_0_0/60%)] -mt-[50px] min-w-[230px] transition-all duration-300 ease-out group-hover:opacity-100 group-hover:pointer-events-auto'
              : 'max-h-[350px] bg-[rgba(255,255,255,0.1)] transition-all duration-200 overflow-y-auto'
          }`"
        >
          <ul>
            <template v-for="subItem in item.subItems">
              <li
                v-if="subItem.state"
                :key="subItem.id"
                class="relative space-y-2"
                :class="
                  toggleSidebar ? 'rounded py-[2.5] relative' : 'group/list'
                "
              >
                <div
                  v-if="subItem.secondaryItems.length > 0"
                  class="flex flex-1"
                >
                  <div
                    class="w-full cursor-pointer"
                    @click.prevent="toggleSecondaryAccordion(subItem.id)"
                  >
                    <span class="flex space-x-2 text-xsm/4 p-3 transition-all duration-300 hover:bg-[rgba(248,250,252,0.20)] hover:text-white">
                      <span class="block whitespace-nowrap truncate">
                        {{ subItem.title }}
                      </span>
                    </span>
                  </div>
                </div>

                <template v-else>
                  <a
                    :href="subItem.link"
                    class="flex flex-wrap text-sm p-3 transition-all duration-300 hover:text-white relative"
                    :class="[
                      toggleSidebar ? 'pl-11' : '',
                      isActive(subItem.link)
                        ? 'bg-[rgba(255,255,255,0.35)]'
                        : 'hover:bg-[rgba(248,250,252,0.20)]',
                    ]"
                  >
                    <span class="block w-full whitespace-nowrap truncate">
                      {{ subItem.title }}
                    </span>
                    <span class="text-[10px]">
                      {{ subItem.sub_text }}
                    </span>
                    <span
                      v-if="subItem.count"
                      class="h-[18px] w-7 absolute top-3 right-3 bg-red-500 rounded flex items-center justify-center text-white font-bold text-[10px]">
                      {{ subItem.count }}
                    </span>
                  </a>
                </template>

                <div
                  v-if="
                    subItem.secondaryItems.length > 0 &&
                    (!toggleSidebar || subItem.secondary_open)
                  "
                  class="p-2"
                  :class="
                    !toggleSidebar
                      ? 'absolute left-[230px] -top-2 z-10 rounded-r bg-[#2c3a50] w-[230px] opacity-0 pointer-events-none group-hover/list:opacity-100 group-hover/list:pointer-events-auto transition-all duration-300'
                      : 'bg-[rgba(255,255,255,0.1)] rounded'
                  "
                >
                  <ul class="space-y-2">
                    <li
                      v-for="secondaryItem in subItem.secondaryItems"
                      :key="secondaryItem.id"
                    >
                      <a
                        :href="secondaryItem.link"
                        class="flex space-x-2 text-xsm/4 p-2 transition-all duration-300 hover:bg-[rgba(248,250,252,0.20)] rounded hover:text-white">
                        <span class="block whitespace-nowrap truncate">
                          {{ secondaryItem.title }}
                        </span>
                      </a>
                    </li>
                  </ul>
                </div>
              </li>
            </template>
          </ul>
        </div>
      </Transition>
    </li>
  </ul>
</template>

<script setup>
import { onMounted, ref, watch } from 'vue';

const props = defineProps({
  toggleSidebar: {
    type: [Boolean, null],
    required: false,
    default: false,
  },
});

const currentPath = ref(window.location.pathname);
const menuItems = ref([
  {
    id: 1,
    icon: '<span class="flex items-center"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 18 18" class="w-4 h-4"><path d="M14 12a3 3 0 1 0-4.5 2.595V18l1.5-1.5 1.5 1.5v-3.405A3.01 3.01 0 0 0 14 12Zm-3 1.5c-.825 0-1.5-.675-1.5-1.5s.675-1.5 1.5-1.5 1.5.675 1.5 1.5-.675 1.5-1.5 1.5ZM11.307 0H4.25A2.257 2.257 0 0 0 2 2.25V18h6v-1.5H3.5V2.25c0-.413.337-.75.75-.75h6v5.25h5.25v9.75H14V18h3V5.692L11.307 0Zm.443 2.558 2.693 2.692H11.75V2.558Z"></path></svg></span>',
    title: 'Job',
    link: '',
    subItems: [
      {
        id: 'job-search',
        title: 'Job Search',
        sub_text: '',
        link: '#',
        secondaryItems: [],
        secondary_open: false,
        state: true,
        count: 0,
      },
      {
        id: 'job-list',
        title: 'Job List',
        sub_text: '',
        link: '#',
        secondaryItems: [],
        secondary_open: false,
        state: true,
        count: 0,
      },
    ],
    open: false,
    new: true,
    count: 0,
    manuallyToggled: false,
  },
]);

const isActive = (route) => {
  return currentPath.value === route;
};

const openParentAccordionIfActiveSubItem = () => {
  menuItems.value.forEach((item) => {
    if (!item.manuallyToggled) {
      item.open = item.subItems.some((subItem) => isActive(subItem.link));
    }
  });
};

const toggleAccordion = (index) => {
  menuItems.value[index].open = !menuItems.value[index].open;
  menuItems.value[index].manuallyToggled = true;

  if (menuItems.value[index].open) {
    menuItems.value.forEach((item, i) => {
      if (i !== index) {
        item.open = false;
        item.manuallyToggled = false;
      }
    });
  }
};

const toggleSecondaryAccordion = (id) => {
  const item = menuItems.value.find((item) =>
    item.subItems.some((subItem) => subItem.id === id)
  );
  const subItem = item.subItems.find((subItem) => subItem.id === id);
  subItem.secondary_open = !subItem.secondary_open;
  if (subItem.secondary_open) {
    item.subItems.forEach((subItem) => {
      if (subItem.id !== id) {
        subItem.secondary_open = false;
      }
    });
  }
};

const isAnySubItemActive = (item) => {
  return item.some((subItem) => isActive(subItem.link));
};

window.addEventListener('popstate', () => {
  currentPath.value = window.location.pathname;
  openParentAccordionIfActiveSubItem();
});

watch( menuItems, () => { openParentAccordionIfActiveSubItem()},
  { deep: true }
);

onMounted(() => {
  openParentAccordionIfActiveSubItem();
});
</script>

Here's an overview of its major features and logic:

  1. Menu Items Rendering: The component renders a list of menu items, each consisting of a title, icon, and potentially sub-items.

  2. Responsive Design: The layout adjusts based on the toggleSidebar state. When toggleSidebar is true, the sidebar expands to show both icons and titles, otherwise, it collapses, showing only icons.

  3. Dynamic Classes and Styles: Various classes and inline styles are conditionally applied based on states like toggleSidebar, active routes, and the presence of sub-items. This enables dynamic UI changes such as background color shifts, icon rotations, and text alignment adjustments.

  4. Event Handling: Click events are handled to toggle the expansion of menu items and sub-items. Additionally, the component listens for changes in the menu items array and updates the state accordingly.

  5. Active Route Highlighting: The component highlights the active route based on the current path (isActive function), ensuring users can easily identify their current location within the menu.

  6. Sub-Item Handling: Sub-items can be expanded or collapsed, and their active states are tracked to maintain proper UI feedback. Additionally, secondary items within sub-items can also be expanded or collapsed independently.

  7. Local Storage Interaction: There's no direct interaction with local storage in this component, but it relies on the currentPath reference to keep track of the active route.


DEMO
You can check the preview and here's the link of the code

Conclusion

Congratulations! ๐ŸŽ‰ You've just created a modern, animated sidebar that's compatible with both Vue 2 and Vue 3, and remembers its state across page reloads thanks to local storage. This sidebar not only enhances the aesthetics of your application but also improves the overall user experience by providing a smooth and interactive way to navigate through your app.

Experiment with different animations, styles, and configurations to make the sidebar uniquely yours. Vue's reactivity and component system make it straightforward to extend and customize components, so feel free to get creative! ๐ŸŒˆ

For further reading on Vue, local storage, and advanced animations, consider checking out the official Vue documentation and CSS animation guides.

Happy coding!

ย