cssvue.jscss-animationsvuejs3vue-transitions

Smooth out animation with custom dropdown input with Vue transition animations


Edit: I've built this on codesandbox. Some of the implementations aren't working for whatever reason (it doesn't like my img src routes)

CodeSandbox Link

So I am trying to build a responsive navbar that has a couple custom dropdowns. Intitally I started with making the dropdown options absolute to the parent element, but that would make it so that I need space between both dropdowns so that they don't overflow/cover each other.

I have them closer together now and the options are no longer relative, but as you can tell from the gif, the language change dropdown now jumps to make space for the options. Shifting up is totally fine and really the only option, but is there some sort of animation or transition that I can use to make it not so jittery/ more smooth?

Any tips or ideas would be greatly appreciated!

Cheers!

enter image description here

LangDropdown.vue

<template>
  <div class="_custom-select" @blur="dropdownIsOpen = false">
    <div
      style="display: flex; flex-direction: column; justify-content: space-between"
      @click="dropdownIsOpen = !dropdownIsOpen"
    >
      <div style="display: flex">
        <img style="width: 34px" class="_icon" src="../assets/languages-icon.svg" alt="Change Language Icon" />
        <div style="flex: 1; display: flex; justify-content: space-between">
          <div v-if="!collapsed" class="_selected-option">
            <div>
              {{ selected }}
            </div>
            <img class="_icon" src="../assets/chevron-down.svg" alt="" />
          </div>
        </div>
      </div>
      <transition name="slide">
        <ul v-if="dropdownIsOpen && !collapsed" class="_options">
          <li v-for="(option, i) of options" :key="i" @click="selectOption(option)">
            {{ option }}
          </li>
        </ul>
      </transition>
    </div>
  </div>
</template>

<script lang="ts" setup>
  import { PropType, ref } from "vue"
  import { collapsed } from "./state"
  const props = defineProps({
    options: { type: Array as PropType<string[]>, required: true },
    default: { type: String, required: true }
  })
  const emit = defineEmits(["input"])
  const selected = ref(props.default ? props.default : props.options.length > 0 ? props.options[0] : null)
  const dropdownIsOpen = ref(true)
  if (collapsed) dropdownIsOpen.value = false
  function selectOption(_option: any) {
    selected.value = _option
    dropdownIsOpen.value = false
    emit("input", _option)
  }
</script>

<style lang="sass">
  ._custom-select
    position: relative
    width: 100%
    text-align: left
    outline: none
    font-size: 16px
    border-radius: 6px
    &:hover
      ._icon,
      ._selected-option
        background-color: var(--sidebar-item-hover)
  ._selected-option
    flex: 1
    display: flex
    justify-content: space-between
    margin-left: 1em
    text-align: left
    border-radius: 6px
    padding: 8px 18px
    cursor: pointer
    user-select: none
    line-height: 26px
  ._icon
    width: 24px
    border-radius: 6px
    cursor: pointer
  ._options
    // position: absolute
    // right: 0
    // top: 100%
    margin: 0
    margin-left: auto
    padding: 8px
    padding-top: 0
    list-style-type: none
    transform-origin: top
    transition: transform 300ms ease-in-out
    overflow: hidden
    > *
      border-radius: 6px
      text-align: left
      cursor: pointer
      user-select: none
      padding: 6px
      width: 100%
    > *:hover
      background-color: var( --sidebar-item-hover)

  .slide-move,
  .slide-enter-from,
  .slide-leave-to
    transform: scaleY(0)

  ._custom-select ._options div:hover
    background-color: var( --sidebar-item-hover)
</style>

Sidebar.vue

<!-- eslint-disable vue/multi-word-component-names -->
<script lang="ts" setup>
  import SidebarLink from "./SidebarLink.vue"
  import { collapsed, toggleSidebar, sidebarWidth } from "./state"
  import LangDropdown from "./LangDropdown.vue"
  import MyAccountDropDown from "./MyAccountDropDown.vue"
  const emit = defineEmits(["change"])
  function changeLang(lang: any) {
    emit("change", lang)
  }
</script>

<template>
  <div class="_sidebar" :style="{ width: sidebarWidth }">
    <div class="_collapse-icon" :class="{ '_rotate-180': collapsed }" @click="toggleSidebar">
      <img src="../assets/chevron-left.svg" alt="Collapse Sidebar" />
    </div>
    <router-link style="text-decoration: none" to="/">
      <div class="_home-link">
        <img style="width: 34px" src="../assets/logo.svg" alt="" />
        <div v-if="!collapsed" class="_home-link-text">Home</div>
      </div>
    </router-link>

    <div class="_sidebar-links">
      <SidebarLink to="/videos" icon="videos-icon" label="Videos" />
      <SidebarLink to="/annotator" icon="annotator-icon" label="Annotator" />
      <SidebarLink to="/training" icon="training-icon" label="Training" />
      <SidebarLink to="/inference" icon="inference-icon" label="Inference" />
      <SidebarLink to="/work-insights" icon="work-insights-icon" label="Work Insights" />
    </div>
    <div class="_dropdowns">
      <LangDropdown
        tabindex="0"
        :options="['English', 'Simplified Chinese', 'Traditional Chinese']"
        :default="'English'"
        @input="changeLang"
      />
      <MyAccountDropDown tabindex="0" />
    </div>
  </div>
</template>

<style lang="sass">
  \:root
    --sidebar-bg-color: #4272ce
    --sidebar-item-hover: #5489ef
    --sidebar-item-active: #5489ef
</style>

<style lang="sass" scoped>
  ._sidebar
    position: relative
    display: flex
    flex-direction: column
    height: 100vh
    width: auto
    padding: 0.5em
    color: white
    background-color: var(--sidebar-bg-color)
    transition: 0.3s ease
    z-index: 1
    &::after
     content: ''
     position: absolute
     top: 0
     bottom: 0
     left: 0
     right: 0
     width: 50px
     height: 100%
     display: block
     background-color: #2d5ab2
     z-index: -1
  ._sidebar-links > *:not(:last-child)
      margin-bottom: 2em


  ._collapse-icon
    position: absolute
    top: 4em
    right: -12px
    display: inline-block
    background-color: #4b4bd9
    width: 1.5em
    height: 1.5em
    border: 0.25em solid #4b4bd9
    border-radius: 50%
    text-align: center
    cursor: pointer

  ._home-link
    position: relative
    display: flex
    align-items: center
    cursor: pointer
    user-select: none
    margin-top: 1em
    margin-bottom: 4em
    border-radius: 0.25em
    height: 1.5em
    color: white
    &-text
      flex: 1
      display: flex
      margin-left: 2rem
      text-align: left
      text-decoration: none
      font-size: 18px

  ._rotate-180
    transform: rotate(180deg)
    transition: 0.3s linear

  ._dropdowns
    margin-top: auto
    margin-bottom: 8em
    > *:first-child
      margin-bottom: 2em
</style>

Solution

  • What you're trying to create is not technically a dropdown, but a collapse.

    By definition, a dropdown is an element which has a toggle and a menu. When opened, the menu is displayed on top of the rest of the page. Typically, it's opaque and features a shadow. Opening the dropdown has no effect on the rest of the page (the layout does not change), the rest of the page does not re-render.

    What you are trying to achieve here is a collapse. Collapse elements have a toggle and a body, very similar to dropdowns. But, unlike dropdowns, when opening, they push everything below, according to their body height. They are much heavier on browser rendering, because they trigger repaints on all layers (layout, paint and copositor), while animating, on all elements changing layout position while the collapse animates (typically on subsequent siblings - but potentially on the entire rest of the page).

    The collapse body has a wrapper element which has maxHeight: 0 initially and then transitions to its scrollHeight value when toggled. This creates a smooth transition for everything under it.

    Here's a basic example, to demonstrate the principle:

    const { 
      createApp,
      defineComponent,
      reactive,
      watchEffect,
      onMounted,
      onBeforeUnmount,
      toRefs
    } = Vue;
    
    const Collapse = defineComponent({
      template: `
      <div class="collapse-toggle" @click="toggle">
        <slot name="toggle">{{ title }}</slot>
      </div>
      <div class="collapse-body" ref="bodyEl" :style="bodyStyle">
        <slot></slot>
      </div>
      `,
      props: {
        title: {
          type: String,
          default: '--'
        }
      },
      setup() {
        const state = reactive({
          isOpen: false,
          bodyEl: null,
          bodyStyle: {},
          toggle: () => state.isOpen = !state.isOpen
        });
        const update = () => state.bodyStyle = {
          maxHeight: `${state.isOpen ? state.bodyEl.scrollHeight : 0}px`
        };
        watchEffect(update);
        onMounted(() => window.addEventListener('resize', update));
        onBeforeUnmount(() => window.removeEventListener('resize', update));
        return toRefs(state)
      }
    })
    
    createApp({
      components: { Collapse }
    }).mount('#app')
    .collapse-body {
      overflow: hidden;
      background-color: #f5f5f5;
      max-height: 0;
      padding: 0 1rem;
      transition: max-height .3s cubic-bezier(.4,0,.2,1);
    }
    .collapse-toggle, .collapse-toggle * {
      cursor: pointer;
    }
    <script src="https://unpkg.com/vue/dist/vue.global.prod.js"></script>
    <div id="app">
      <Collapse title="Collapse 1">
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
      </Collapse>
      <Collapse title="Collapse 2">
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
      </Collapse>
      <Collapse>
        <template #toggle>
          <button>With #toggle slot</button>
        </template>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>Lorem ipsum dolor sit amet.</p>
      </Collapse>
    </div>

    Important note: Avoid setting top/bottom margins on the collapse wrapper. They'll create jumps in the animation. If you need such spacing, place them as top padding on the first content element, or bottom padding on the last one, respectively.