Creating a Reusable Collapsible Panel Component in Vue 3

Transition Handler Component

Define a transition wrapper to animate expand and collapse actions.

<template>
  <transition
    @before-enter="prepEnter"
    @enter="startEnter"
    @after-enter="finishEnter"
    @before-leave="prepLeave"
    @leave="startLeave"
    @after-leave="finishLeave"
  >
    <slot />
  </transition>
</template>

<script setup lang="ts">
function prepEnter(elem: HTMLElement) {
  elem.classList.add('anim-slide');
  elem.dataset.initPadTop = elem.style.paddingTop || '';
  elem.dataset.initPadBot = elem.style.paddingBottom || '';
  elem.dataset.initOver = elem.style.overflow || '';
  elem.style.overflow = 'hidden';
  elem.style.maxHeight = '0';
  elem.style.paddingTop = '0';
  elem.style.paddingBottom = '0';
}

function startEnter(elem: HTMLElement) {
  elem.style.maxHeight = `${elem.scrollHeight}px`;
  elem.style.paddingTop = elem.dataset.initPadTop!;
  elem.style.paddingBottom = elem.dataset.initPadBot!;
}

function finishEnter(elem: HTMLElement) {
  elem.classList.remove('anim-slide');
  elem.style.maxHeight = '';
  elem.style.overflow = elem.dataset.initOver!;
}

function prepLeave(elem: HTMLElement) {
  elem.dataset.initPadTop = elem.style.paddingTop || '';
  elem.dataset.initPadBot = elem.style.paddingBottom || '';
  elem.dataset.initOver = elem.style.overflow || '';
  elem.style.maxHeight = `${elem.scrollHeight}px`;
  elem.style.overflow = 'hidden';
}

function startLeave(elem: HTMLElement) {
  elem.classList.add('anim-slide');
  elem.style.maxHeight = '0';
  elem.style.paddingTop = '0';
  elem.style.paddingBottom = '0';
}

function finishLeave(elem: HTMLElement) {
  elem.classList.remove('anim-slide');
  elem.style.maxHeight = '';
  elem.style.overflow = elem.dataset.initOver!;
  elem.style.paddingTop = elem.dataset.initPadTop!;
  elem.style.paddingBottom = elem.dataset.initPadBot!;
}
</script>

<style scoped>
.anim-slide {
  transition: all 0.4s ease-in-out;
}
</style>

Individual Panel Item

Compose a collapsible section with header and enimated content area.

<template>
  <div class="panel-block">
    <header class="panel-header" @click.stop="switchPanel">
      <div class="header-main">
        <span v-if="!$slots.headerText" class="default-header">{{ propTitle }}</span>
        <slot name="headerText" />
      </div>
      <div class="indicator">
        <span class="label">Details</span>
        <el-icon class="arrow-icon" :class="{ rotated: isOpen }">
          <ArrowRight />
        </el-icon>
      </div>
    </header>
    <SlideAnim>
      <section v-show="isOpen" class="panel-body">
        <slot name="bodyContent" />
      </section>
    </SlideAnim>
  </div>
</template>

<script setup lang="ts">
import { useSlots, useAttrs, inject, computed } from 'vue';
import SlideAnim from './TransitionHandler.vue';

const slots = useSlots();
const attrs = useAttrs();
const activeSet: any = inject('activeSet');
const switchFn: any = inject('switchPanel');

const propTitle = attrs.title ?? '';
const isOpen = computed(() => activeSet.value.includes(attrs.name));

function switchPanel() {
  switchFn(attrs.name);
}
</script>

<style scoped>
.panel-block {
  display: flex;
  flex-direction: column;
}
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-radius: 4px 4px 0 0;
  overflow: hidden;
}
.indicator {
  display: flex;
  align-items: center;
  color: #2578f2;
  cursor: pointer;
  user-select: none;
}
.arrow-icon {
  margin-left: 6px;
  font-size: 16px;
  transition: transform 0.3s;
  transform-origin: center;
}
.arrow-icon.rotated {
  transform: rotate(90deg);
}
.header-main {
  flex: 1;
  min-width: 0;
}
.default-header {
  font-size: 14px;
  color: #1b1b1b;
}
</style>

Container for Multiple Panels

Manage group state and provide context to children.

<template>
  <div class="panels-wrapper">
    <slot />
  </div>
</template>

<script setup lang="ts">
import { ref, watch, provide, defineProps, defineEmits } from 'vue';

const props = defineProps({
  modelValue: [String, Number, Array],
  accordion: Boolean
});
const emit = defineEmits(['update:modelValue', 'change']);

const activeSet = ref<any[]>([]);

watch(() => props.modelValue, initActive, { immediate: true });

function initActive() {
  activeSet.value = Array.isArray(props.modelValue)
    ? [...props.modelValue]
    : [props.modelValue];
}

function switchPanel(key: any) {
  if (activeSet.value.includes(key)) {
    activeSet.value = activeSet.value.filter(k => k !== key);
  } else {
    if (props.accordion) {
      activeSet.value = [key];
    } else {
      activeSet.value.push(key);
    }
  }
  emit('update:modelValue', activeSet.value);
  emit('change', activeSet.value);
}

provide('activeSet', activeSet);
provide('switchPanel', switchPanel);
</script>

<style scoped>
.panels-wrapper {
  /* container styles if needed */
}
</style>

Usage Example

Integrate components to buildd a collapsible interface.

<template>
  <div class="demo-box">
    <PanelsWrapper v-model="openKey" :accordion="true" @change="onChange">
      <PanelItem :name="1">
        <template #headerText>
          <!-- custom header -->
        </template>
        <template #bodyContent>
          <!-- custom body -->
        </template>
      </PanelItem>
    </PanelsWrapper>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import PanelsWrapper from './Container.vue';
import PanelItem from './Item.vue';

const openKey = ref<number[]>([1]);

function onChange(val: number[]) {
  console.log(val);
}
</script>

<style scoped>
.demo-box {
  padding: 20px;
}
</style>

Tags: vue3 Component Design animation TypeScript frontend

Posted on Sun, 23 Aug 2026 16:38:30 +0000 by jstngk