Build a Multilevel Accordion Menu in Vue 3 with Transitions

by Didin J. on Oct 13, 2025 Build a Multilevel Accordion Menu in Vue 3 with Transitions

Build a reusable, accessible multilevel accordion menu in Vue 3 + TypeScript with smooth auto-height transitions and plain CSS — perfect for nested navigation.

A multilevel accordion is a common UI pattern for navigation, FAQs, or side menus. In this tutorial you'll build a reusable, accessible multilevel accordion in Vue 3 + TypeScript using Vite and plain CSS. The accordion supports unlimited nesting, smooth height transitions, keyboard interactions, and is easy to style.

Prerequisites

  • Node.js (v16+ recommended)

  • Familiarity with Vue 3 Composition API and TypeScript

  • Basic CSS knowledge


Project setup (Vite + TypeScript)

Create a new Vite project with Vue + TS.

npm create vite@latest vue-multilevel-accordion -- --template vue-ts

Vite + Vue application will automatically run. Open your browser the go to http://localhost:5173/.

Build a Multilevel Accordion Menu in Vue 3 with Transitions - vite + vue

Stop the Vue application, then go to the newly created Vite + Vue application.

cd vue-multilevel-accordion

Open src/ in your editor. We'll add components and a sample data file.

Folder structure

src/
├─ assets/
├─ components/
│  ├─ Accordion.vue
│  └─ AccordionItem.vue
├─ data/
│  └─ menu.ts
├─ App.vue
└─ main.ts

Create src/data/menu.ts:

// src/data/menu.ts
export interface MenuItem {
  id: string | number;
  title: string;
  url?: string;
  children?: MenuItem[];
  // optional: initiallyOpen?: boolean;
}

export const menuData: MenuItem[] = [
  {
    id: 1,
    title: "Home",
    url: "/",
  },
  {
    id: 2,
    title: "Products",
    children: [
      {
        id: 21,
        title: "UI Kits",
        children: [
          { id: 211, title: "Admin Kits", url: "/products/admin-kits" },
          { id: 212, title: "Component Kits", url: "/products/component-kits" },
        ],
      },
      {
        id: 22,
        title: "Integrations",
        children: [
          { id: 221, title: "Payments", url: "/products/payments" },
          { id: 222, title: "Analytics", url: "/products/analytics" },
        ],
      },
    ],
  },
  {
    id: 3,
    title: "About",
    url: "/about",
  },
];


Accordion component (container)

Accordion.vue — simple wrapper to host top-level items.

<!-- src/components/Accordion.vue -->
<template>
  <nav class="accordion" aria-label="Main accordion menu">
    <ul class="accordion-list">
      <AccordionItem
        v-for="item in items"
        :key="item.id"
        :item="item"
        :level="1"
      />
    </ul>
  </nav>
</template>

<script lang="ts">
import { defineComponent, type PropType } from "vue";
import AccordionItem from "./AccordionItem.vue";
import type { MenuItem } from "../data/menu";

export default defineComponent({
  name: "Accordion",
  components: { AccordionItem },
  props: {
    items: {
      type: Array as PropType<MenuItem[]>,
      required: true,
    },
  },
});
</script>

<style>
/* minimal reset for list look */
.accordion-list { list-style: none; margin: 0; padding: 0; }
</style>


Recursive AccordionItem with smooth height transitions

AccordionItem.vue — this handles one node and recursively renders children. It includes enter/leave hooks to animate height (auto-height animation), keyboard support (Enter/Space to toggle), rotating chevron, and ARIA attributes.

<template>
  <div class="accordion-item">
    <div class="accordion-header" @click="toggle">
      <span class="accordion-title">{{ item.title }}</span>
      <span v-if="item.children" class="accordion-icon">
        {{ isOpen ? "−" : "+" }}
      </span>
    </div>

    <transition name="accordion">
      <div v-if="isOpen" class="accordion-content">
        <p v-if="!item.children">{{ item.content }}</p>

        <div v-else>
          <AccordionItem
            v-for="(child, index) in item.children"
            :key="index"
            :item="child"
          />
        </div>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

interface AccordionItemData {
  title: string;
  content?: string;
  children?: AccordionItemData[];
}

const props = defineProps<{
  item: AccordionItemData;
}>();

const isOpen = ref(false);

function toggle() {
  isOpen.value = !isOpen.value;
}
</script>

<style scoped>
.accordion-item {
  border: 1px solid #ddd;
  border-radius: 6px;
  margin: 6px 0;
  overflow: hidden;
}

.accordion-header {
  background: #f9f9f9;
  cursor: pointer;
  padding: 12px 16px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.accordion-title {
  font-weight: 600;
}

.accordion-icon {
  font-size: 20px;
  font-weight: bold;
}

.accordion-content {
  padding: 10px 16px;
  background: #fff;
  border-top: 1px solid #eee;
}

.accordion-enter-active,
.accordion-leave-active {
  transition: all 0.3s ease;
  overflow: hidden;
}

.accordion-enter-from,
.accordion-leave-to {
  max-height: 0;
  opacity: 0;
}

.accordion-enter-to,
.accordion-leave-from {
  max-height: 200px;
  opacity: 1;
}
</style>

Notes on the JS-driven transition

Vue’s v-show + <transition> hooks are used to animate between height: 0 and the element’s scrollHeight. This gives a smoother “auto height” animation than just max-height CSS hacks.


App.vue — example usage

<!-- src/App.vue -->
<template>
  <div id="app" class="app">
    <header class="site-header">
      <h1>Multilevel Accordion Menu (Vue 3 + TS)</h1>
    </header>

    <main>
      <Accordion :items="menu"/>
    </main>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import Accordion from "./components/Accordion.vue";
import { menuData } from "./data/menu";

export default defineComponent({
  name: "App",
  components: { Accordion },
  setup() {
    return { menu: menuData };
  },
});
</script>

<style>
:root {
  --bg: #ffffff;
  --muted: #666;
  --border: #e6e6e6;
  --accent: #0b74de;
}

body {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
  margin: 0;
  background: var(--bg);
  color: #111;
}

.app {
  max-width: 880px;
  margin: 32px auto;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: #fff;
}

.site-header h1 {
  margin: 0 0 16px 0;
  font-size: 20px;
}
</style>


Accessibility & keyboard interactions

  • Each header button has aria-expanded, aria-controls and links the id/aria-labelledby to the panel.

  • The button uses Enter and Space to toggle.

  • You can extend keyboard support:

    • Arrow Down / Arrow Up to move focus between items.

    • Home / End to jump to first/last.

    • Use roving tabindex or manage focus via JS if you need complex keyboard navigation.


Styling tips & customization

  • Chevron: replace SVG with an icon font if desired.

  • Indentation: currently padding-left scales with level. Adjust the multiplier to match your design.

  • Open-only-one-child behavior: if you want only one child open at a time per level (accordion behavior), maintain an openId at the parent level and pass handlers to children. For multiselect (any number open), the current implementation works fine.


Performance & edge cases

  • Deep nesting is supported; recursion only renders visible nodes in the DOM when open.

  • For very deep trees, you may consider virtualization.

  • For server-side rendering, be sure not to run DOM measurement code until after mount if SSR is required.


Optional: Single-open behavior (example snippet)

If you want to allow only one sibling open at a time per parent, manage openChildId in the parent, and give children a :isOpen prop, and @toggle event. (I can provide a full example if you want that behavior.)


Deploy

Build for production:

npm run build
# then serve with any static host, e.g. Netlify, Vercel, or static server

You can find the full source code on our GitHub.

That's just the basics. If you need more deep learning about Vue/Vue.js, you can take the following cheap course:

Thanks!