How to add a smooth Vue collapse transition on v-if
Asked Answered
H

4

29

I'm struggling with Vue transitions trying to show/hide content using v-if smoothly. Whilst I understand the CSS classes and transitions, I can make the content appear 'smoothly' using things like opacity or translation, etc... but once the animation is complete (or rather as it starts), any HTML sections below seem to 'jump'.

I'm trying to achieve the same effect as the Bootstrap 4 'collapse' class - click one of the top buttons here: https://getbootstrap.com/docs/4.0/components/collapse/

As the hidden section appears/disappears, all the HTML content 'slides' nicely with it.

Is it possible to use Vue transition for content being shown using v-if? All the samples on the Vue transitions docs, whilst having great CSS transition effects, have the below HTML 'jump' once the transition has started or is complete.

I've seen some pure JS solutions using max-height - https://jsfiddle.net/wideboy32/7ap15qq0/134/

and tried with Vue: https://jsfiddle.net/wideboy32/eywraw8t/303737/

.smooth-enter-active, .smooth-leave-active {
  transition: max-height .5s;
}
.smooth-enter, .smooth-leave-to {
  max-height: 0 .5s;
}
Houseroom answered 25/8, 2018 at 19:31 Comment(0)
M
19

If you want to animate max-height, then you should enter the amount of max-height for the element you want to animate, also correct the second class as you put 's' (or seconds) in max-height definition:

p{
  max-height: 20px;
}
.smooth-enter-active, .smooth-leave-active {
  transition: max-height .5s;
}
.smooth-enter, .smooth-leave-to {
  max-height: 0;
}

if you want something like bs4 collapse then the example inside vue website will do :

.smooth-enter-active, .smooth-leave-active {
  transition: opacity .5s;
}
.smooth-enter, .smooth-leave-to {
  opacity: 0
}

What you are trying to do is achievable by first finding out the height of the content and then setting it inside .*-enter-to and .*-leave classes. One way to do that is demonstrated in JSFiddle below:

https://jsfiddle.net/rezaxdi/sxgyj1f4/3/

You can also completely forget about v-if or v-show and just hide the element using height value which I think is a lot smoother:

https://jsfiddle.net/rezaxdi/tgfabw65/9/

Mathilda answered 25/8, 2018 at 19:46 Comment(3)
Thanks @Mathilda - a combination of max-height and opacity works well, however it's not generic - max-height needs to be set exactly - and changes depending on the amount of content being shown and the users screen size. Is there a way to do this during the transition? I still think this is the wrong approach! jsfiddle.net/wideboy32/eywraw8t/304526. Your suggestion from vue docs (just opacity) displays the issue - the opacity is smooth, but once the content is shown or hidden, html elements below are shifted in one jump rather than a smooth flow up/down.Houseroom
That first suggestion is working really well - it's even automatically adjusting the height and moving content below properly as the screen size is changed by the user. Thanks loads @r3zaxd1. For anyone else looking at this, v-if is probably off the menu - as the elements aren't yet in the DOM(?) so working with v-show is the way forward. This article also explains the general difficulties of these transitions - css-tricks.com/using-css-transitions-auto-dimensions and so a pure js without vue / v-if/show/transitions is probably the way forward. I'll post back if I find anything else!Houseroom
Thanks! Didn't want the neccesarity of adding a new component for this, your solution works. Copying the CSS and assigning a max height & initial height (cannot be inherited apparently).Gradus
E
27

i also had similar task. I found that it isn't possible to do it without JS. So i write custom transition component ( Reusable Transitions ) and it works for me:

Vue.component('transition-collapse-height', {
  template: `<transition
    enter-active-class="enter-active"
    leave-active-class="leave-active"
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
  >
    <slot />
  </transition>`,
  methods: {
    /**
     * @param {HTMLElement} element
     */
    beforeEnter(element) {
      requestAnimationFrame(() => {
        if (!element.style.height) {
          element.style.height = '0px';
        }

        element.style.display = null;
      });
    },
    /**
     * @param {HTMLElement} element
     */
    enter(element) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          element.style.height = `${element.scrollHeight}px`;
        });
      });
    },
    /**
     * @param {HTMLElement} element
     */
    afterEnter(element) {
      element.style.height = null;
    },
    /**
     * @param {HTMLElement} element
     */
    beforeLeave(element) {
      requestAnimationFrame(() => {
        if (!element.style.height) {
          element.style.height = `${element.offsetHeight}px`;
        }
      });
    },
    /**
     * @param {HTMLElement} element
     */
    leave(element) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          element.style.height = '0px';
        });
      });
    },
    /**
     * @param {HTMLElement} element
     */
    afterLeave(element) {
      element.style.height = null;
    },
  },
});

new Vue({
  el: '#app',
  data: () => ({
    isOpen: true,
  }),
  methods: {
    onClick() {
      this.isOpen = !this.isOpen;
    }
  }
});
.enter-active,
.leave-active {
  overflow: hidden;
  transition: height 1s linear;
}

.content {
  background: grey;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
  <button @click="onClick">
    open/hide
  </button>
  <transition-collapse-height>
   <div v-show="isOpen" class="content">
     <br/>
     <br/>
     <br/>
     <br/>
   </div>
  </transition-collapse-height>
</div>
Entrepreneur answered 13/3, 2019 at 9:4 Comment(4)
I would be interested to see a solution which is really using v-if . Why: I have many sections with many elements which could be potentially expanded / collapsed and I like to save rendering time for the (mostly) collapsed sections.Leitmotif
It will work for v-if too, I don't remember where it described, but v-if + <transition> have kind of integration, so v-if will remove element once animation is finishedEntrepreneur
@AlexandrVysotsky why do you nest requestAnimationFrame() calls?Androgen
@Androgen I don't remember exactly, but I guess that beforeEnter and enter can be called in the same time(in the same frame), so we need to be sure that enter will be called after beforeEnter frame.Entrepreneur
M
19

If you want to animate max-height, then you should enter the amount of max-height for the element you want to animate, also correct the second class as you put 's' (or seconds) in max-height definition:

p{
  max-height: 20px;
}
.smooth-enter-active, .smooth-leave-active {
  transition: max-height .5s;
}
.smooth-enter, .smooth-leave-to {
  max-height: 0;
}

if you want something like bs4 collapse then the example inside vue website will do :

.smooth-enter-active, .smooth-leave-active {
  transition: opacity .5s;
}
.smooth-enter, .smooth-leave-to {
  opacity: 0
}

What you are trying to do is achievable by first finding out the height of the content and then setting it inside .*-enter-to and .*-leave classes. One way to do that is demonstrated in JSFiddle below:

https://jsfiddle.net/rezaxdi/sxgyj1f4/3/

You can also completely forget about v-if or v-show and just hide the element using height value which I think is a lot smoother:

https://jsfiddle.net/rezaxdi/tgfabw65/9/

Mathilda answered 25/8, 2018 at 19:46 Comment(3)
Thanks @Mathilda - a combination of max-height and opacity works well, however it's not generic - max-height needs to be set exactly - and changes depending on the amount of content being shown and the users screen size. Is there a way to do this during the transition? I still think this is the wrong approach! jsfiddle.net/wideboy32/eywraw8t/304526. Your suggestion from vue docs (just opacity) displays the issue - the opacity is smooth, but once the content is shown or hidden, html elements below are shifted in one jump rather than a smooth flow up/down.Houseroom
That first suggestion is working really well - it's even automatically adjusting the height and moving content below properly as the screen size is changed by the user. Thanks loads @r3zaxd1. For anyone else looking at this, v-if is probably off the menu - as the elements aren't yet in the DOM(?) so working with v-show is the way forward. This article also explains the general difficulties of these transitions - css-tricks.com/using-css-transitions-auto-dimensions and so a pure js without vue / v-if/show/transitions is probably the way forward. I'll post back if I find anything else!Houseroom
Thanks! Didn't want the neccesarity of adding a new component for this, your solution works. Copying the CSS and assigning a max height & initial height (cannot be inherited apparently).Gradus
R
9

Here is my Vue3 solution based on Web Animation API, see demo It is rather similar to the one by Alexandr Vysotsky posted here before, but this one will also preserve the initial height of the block.

I started with this blog post and somehow improved it (mostly to keep the initial style of the content block after the end of the transition). The main change is the switch to Web Animation API, which seems as performant as pure CSS animation and provides much more control. This also had eliminated all performance optimization hack from the original solution.

<script setup lang="ts">
interface Props {
  duration?: number;
  easingEnter?: string;
  easingLeave?: string;
  opacityClosed?: number;
  opacityOpened?: number;
}

const props = withDefaults(defineProps<Props>(), {
  duration: 250,
  easingEnter: "ease-in-out",
  easingLeave: "ease-in-out",
  opacityClosed: 0,
  opacityOpened: 1,
});

const closed = "0px";

interface initialStyle {
  height: string;
  width: string;
  position: string;
  visibility: string;
  overflow: string;
  paddingTop: string;
  paddingBottom: string;
  borderTopWidth: string;
  borderBottomWidth: string;
  marginTop: string;
  marginBottom: string;
}

function getElementStyle(element: HTMLElement) {
  return {
    height: element.style.height,
    width: element.style.width,
    position: element.style.position,
    visibility: element.style.visibility,
    overflow: element.style.overflow,
    paddingTop: element.style.paddingTop,
    paddingBottom: element.style.paddingBottom,
    borderTopWidth: element.style.borderTopWidth,
    borderBottomWidth: element.style.borderBottomWidth,
    marginTop: element.style.marginTop,
    marginBottom: element.style.marginBottom,
  };
}

function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
  const { width } = getComputedStyle(element);
  element.style.width = width;
  element.style.position = "absolute";
  element.style.visibility = "hidden";
  element.style.height = "";
  let { height } = getComputedStyle(element);
  element.style.width = initialStyle.width;
  element.style.position = initialStyle.position;
  element.style.visibility = initialStyle.visibility;
  element.style.height = closed;
  element.style.overflow = "hidden";
  return initialStyle.height && initialStyle.height != closed
    ? initialStyle.height
    : height;
}

function animateTransition(
  element: HTMLElement,
  initialStyle: initialStyle,
  done: () => void,
  keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
  options?: number | KeyframeAnimationOptions
) {
  const animation = element.animate(keyframes, options);
  // Set height to 'auto' to restore it after animation
  element.style.height = initialStyle.height;
  animation.onfinish = () => {
    element.style.overflow = initialStyle.overflow;
    done();
  };
}

function getEnterKeyframes(height: string, initialStyle: initialStyle) {
  return [
    {
      height: closed,
      opacity: props.opacityClosed,
      paddingTop: closed,
      paddingBottom: closed,
      borderTopWidth: closed,
      borderBottomWidth: closed,
      marginTop: closed,
      marginBottom: closed,
    },
    {
      height,
      opacity: props.opacityOpened,
      paddingTop: initialStyle.paddingTop,
      paddingBottom: initialStyle.paddingBottom,
      borderTopWidth: initialStyle.borderTopWidth,
      borderBottomWidth: initialStyle.borderBottomWidth,
      marginTop: initialStyle.marginTop,
      marginBottom: initialStyle.marginBottom,
    },
  ];
}

function enterTransition(element: Element, done: () => void) {
  const HTMLElement = element as HTMLElement;
  const initialStyle = getElementStyle(HTMLElement);
  const height = prepareElement(HTMLElement, initialStyle);
  const keyframes = getEnterKeyframes(height, initialStyle);
  const options = { duration: props.duration, easing: props.easingEnter };
  animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}

function leaveTransition(element: Element, done: () => void) {
  const HTMLElement = element as HTMLElement;
  const initialStyle = getElementStyle(HTMLElement);
  const { height } = getComputedStyle(HTMLElement);
  HTMLElement.style.height = height;
  HTMLElement.style.overflow = "hidden";
  const keyframes = getEnterKeyframes(height, initialStyle).reverse();
  const options = { duration: props.duration, easing: props.easingLeave };
  animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}
</script>

<template>
  <Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
    <slot />
  </Transition>
</template>
Ricercar answered 10/3, 2022 at 15:4 Comment(0)
P
0

I slightly changed @kostyfisik's solution by adding width variation, so you can choose which measurement to animate with mode props.

<script setup lang="ts">
interface IExpandAnimationProps {
  duration?: number;
  easingEnter?: string;
  easingLeave?: string;
  opacityClosed?: number;
  opacityOpened?: number;
  mode?: 'width' | 'height';
}

interface initialStyle {
  height: string;
  width: string;
  position: string;
  visibility: string;
  overflow: string;
  paddingTop: string;
  paddingBottom: string;
  paddingLeft: string;
  paddingRight: string;
  borderTopWidth: string;
  borderBottomWidth: string;
  borderLeftWidth: string;
  borderRightWidth: string;
  marginTop: string;
  marginBottom: string;
  marginLeft: string;
  marginRight: string;
}

const props = withDefaults(defineProps<IExpandAnimationProps>(), {
  duration: 300,
  easingEnter: 'ease-in-out',
  easingLeave: 'ease-in-out',
  opacityClosed: 0,
  opacityOpened: 1,
  mode: 'height',
});

const closed = '0px';

function getElementStyle(element: HTMLElement): initialStyle {
  return {
    height: element.style.height,
    width: element.style.width,
    position: element.style.position,
    visibility: element.style.visibility,
    overflow: element.style.overflow,
    paddingTop: element.style.paddingTop,
    paddingBottom: element.style.paddingBottom,
    paddingLeft: element.style.paddingLeft,
    paddingRight: element.style.paddingRight,
    borderTopWidth: element.style.borderTopWidth,
    borderBottomWidth: element.style.borderBottomWidth,
    borderLeftWidth: element.style.borderLeftWidth,
    borderRightWidth: element.style.borderRightWidth,
    marginTop: element.style.marginTop,
    marginBottom: element.style.marginBottom,
    marginLeft: element.style.marginLeft,
    marginRight: element.style.marginRight,
  };
}

function prepareElement(element: HTMLElement, initialStyle: initialStyle): string {
  let width, height;
  if (props.mode === 'height') {
    element.style.width = getComputedStyle(element).width;
    element.style.position = 'absolute';
    element.style.visibility = 'hidden';
    element.style.height = '';
    height = getComputedStyle(element).height;
    element.style.width = initialStyle.width;
    element.style.position = initialStyle.position;
    element.style.visibility = initialStyle.visibility;
    element.style.height = closed;
    element.style.overflow = 'hidden';
    return initialStyle.height && initialStyle.height != closed ? initialStyle.height : height;
  } else {
    element.style.height = getComputedStyle(element).height;
    element.style.position = 'absolute';
    element.style.visibility = 'hidden';
    element.style.width = '';
    width = getComputedStyle(element).width;
    element.style.height = initialStyle.height;
    element.style.position = initialStyle.position;
    element.style.visibility = initialStyle.visibility;
    element.style.width = closed;
    element.style.overflow = 'hidden';
    return initialStyle.width && initialStyle.width != closed ? initialStyle.width : width;
  }
}

function animateTransition(
  element: HTMLElement,
  initialStyle: initialStyle,
  done: () => void,
  keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
  options?: number | KeyframeAnimationOptions,
): void {
  const animation = element.animate(keyframes, options);
  if (props.mode === 'height') {
    element.style.height = initialStyle.height;
  } else {
    element.style.width = initialStyle.width;
  }
  animation.onfinish = () => {
    element.style.overflow = initialStyle.overflow;
    done();
  };
}

function getEnterKeyframes(measurement: string, initialStyle: initialStyle): Keyframe[] {
  if (props.mode === 'height') {
    return [
      {
        height: closed,
        opacity: props.opacityClosed,
        paddingTop: closed,
        paddingBottom: closed,
        borderTopWidth: closed,
        borderBottomWidth: closed,
        marginTop: closed,
        marginBottom: closed,
      },
      {
        height: measurement,
        opacity: props.opacityOpened,
        paddingTop: initialStyle.paddingTop,
        paddingBottom: initialStyle.paddingBottom,
        borderTopWidth: initialStyle.borderTopWidth,
        borderBottomWidth: initialStyle.borderBottomWidth,
        marginTop: initialStyle.marginTop,
        marginBottom: initialStyle.marginBottom,
      },
    ];
  } else {
    return [
      {
        width: closed,
        opacity: props.opacityClosed,
        paddingLeft: closed,
        paddingRight: closed,
        borderLeftWidth: closed,
        borderRightWidth: closed,
        marginLeft: closed,
        marginRight: closed,
      },
      {
        width: measurement,
        opacity: props.opacityOpened,
        paddingLeft: initialStyle.paddingLeft,
        paddingRight: initialStyle.paddingRight,
        borderLeftWidth: initialStyle.borderLeftWidth,
        borderRightWidth: initialStyle.borderRightWidth,
        marginLeft: initialStyle.marginLeft,
        marginRight: initialStyle.marginRight,
      },
    ];
  }
}

function enterTransition(element: Element, done: () => void) {
  const HTMLElement = element as HTMLElement;
  const initialStyle = getElementStyle(HTMLElement);
  const measurement = prepareElement(HTMLElement, initialStyle);
  const keyframes = getEnterKeyframes(measurement, initialStyle);
  const options = { duration: props.duration, easing: props.easingEnter };
  animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}

function leaveTransition(element: Element, done: () => void) {
  const HTMLElement = element as HTMLElement;
  const initialStyle = getElementStyle(HTMLElement);
  let measurement;
  if (props.mode === 'height') {
    measurement = getComputedStyle(HTMLElement).height;
    HTMLElement.style.height = measurement;
  } else {
    measurement = getComputedStyle(HTMLElement).width;
    HTMLElement.style.width = measurement;
  }

  HTMLElement.style.overflow = 'hidden';
  const keyframes = getEnterKeyframes(measurement, initialStyle).reverse();
  const options = { duration: props.duration, easing: props.easingLeave };
  animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}
</script>

<template>
  <Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
    <slot />
  </Transition>
</template>
Parlando answered 13/3 at 13:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.