Search code examples
javascriptcssvue.jsvuejs-transition

Vuejs - transition-group carousel animation one item at a time


I'm trying to implement exactly this carousel/slide effect using Vue transition-group:

Example GIF

As you can see, they are animating one list item (step) at a time where the previous step goes up and the current one goes up with its content following the timeline.

I'm not sure what I am trying to accomplish is possible using transition-group, since the whole parent block will animate, not it's children nodes. If that is the case, I would be happy if I could at least animate the parent block.

Another caveat is that, as I am using transition-group without v-if, or without a filtered list, all steps are being rendered by default which isn't good.

This is my HTML structure:

<transition-group class="steps-viewport" name="steps" tag="div">
  <div v-for="step in currentStep" :key="step.order" class="step-wrapper">
    <h3 class="is-size-5 mb-6 has-text-grey-light">
      Passo {{ step.order }}
    </h3>
    <h1 class="is-size-3">{{ step.title }}</h1>
    <h2 class="is-size-4 mt-2 has-text-grey">{{ step.headline }}</h2>
    <component
      class="mt-5"
      v-bind:is="step.component"
      @status-changed="handleStatusChange($event)"
    ></component>
  </div>
</transition-group>

And this is my CSS:

.component-wrapper {
  width: 100%;

  .steps-viewport {
    height: calc(100vh - 10rem);
    overflow: hidden;
    display: flex;
    flex-direction: column;

    .step-wrapper {
      flex: 0 0 calc(100vh - 10rem);
      display: flex;
      justify-content: center;
      flex-direction: column;
    }
  }
}

Last but not least, the script of my component:

import ProductInfo from "./ProductInfo";

export default {
  components: {
    ProductInfo
  },

  props: {
    defaultActiveStep: {
      type: Number,
      default: 1
    }
  },

  watch: {
    activeStep() {
      this.$emit("step-changed", this.activeStep);
    }
  },

  computed: {
    currentStep() {
      return this.steps.filter(s => s.order === this.activeStep);
    }
  },

  data: () => {
    return {
      activeStep: 1,
      steps: [
        {
          order: 1,
          title: "Title 1?",
          headline:
            "Headline 1",
          component: "product-info"
        },
        {
          order: 2,
          title: "Title 2",
          headline:
            "Headline 2.",
          component: "product-info"
        },
        {
          order: 3,
          title: "Title 3",
          headline:
            "Headline 3.",
          component: "product-info"
        },
        {
          order: 4,
          title: "Title 4!",
          headline:
            "Headline 4",
          component: "product-info"
        }
      ]
    };
  },

  methods: {
    handleStatusChange(status) {
      
      const first = this.steps.shift();
      this.steps = this.steps.concat(first);
    }
  }
};

Solution

  • You need to define special classes to target different stages of the transition, in this case .steps-enter-active (mid-transition state) and .steps-enter-to (end-state).

    For it to occur on page-load, you also need to pass the appear attribute.


    If you want the entire order block to transition you can do so like this:

    new Vue({
      el: '#app',
    
      computed: {
        currentStep() {
          return this.steps.filter(s => s.order === this.activeStep);
        }
      },
    
      data: () => {
        return {
          activeStep: 1,
          steps: [{
              order: 1,
              title: "Title 1?",
              headline: "Headline 1",
              component: "product-info"
            },
            {
              order: 2,
              title: "Title 2",
              headline: "Headline 2.",
              component: "product-info"
            },
            {
              order: 3,
              title: "Title 3",
              headline: "Headline 3.",
              component: "product-info"
            },
            {
              order: 4,
              title: "Title 4!",
              headline: "Headline 4",
              component: "product-info"
            }
          ]
        };
      },
    });
    
    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    .component-wrapper {
      width: 100%;
    }
    
    .steps-viewport {
      height: calc(100vh - 10rem);
      /* overflow: hidden */
      display: flex;
      flex-direction: column;
    }
    
    .step-wrapper {
      flex: 0 0 calc(100vh - 10rem);
      display: flex;
      justify-content: center;
      flex-direction: column;
    }
    
    .steps-enter-active {
      opacity: 0;
      transform: translateY(100%);
      transition: all 0.4s;
    }
    
    .steps-enter-to {
      opacity: 1;
      transform: translateY(0);
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
    
    <div id="app">
      <transition-group class="steps-viewport" name="steps" tag="div" appear>
        <div v-for="step in currentStep" :key="step.order" class="step-wrapper">
          <h3 class="is-size-5 mb-6 has-text-grey-light">
            Passo {{ step.order }}
          </h3>
          <h1 class="is-size-3">{{ step.title }}</h1>
          <h2 class="is-size-4 mt-2 has-text-grey">{{ step.headline }}</h2>
        </div>
      </transition-group>
    </div>


    If you want each element within to transition, you can do so like this, adding a transition-delay:

    new Vue({
      el: '#app',
    
      computed: {
        currentStep() {
          return this.steps.filter(s => s.order === this.activeStep);
        }
      },
    
      data: () => {
        return {
          activeStep: 1,
          steps: [{
              order: 1,
              title: "Title 1?",
              headline: "Headline 1",
              component: "product-info"
            },
            {
              order: 2,
              title: "Title 2",
              headline: "Headline 2.",
              component: "product-info"
            },
            {
              order: 3,
              title: "Title 3",
              headline: "Headline 3.",
              component: "product-info"
            },
            {
              order: 4,
              title: "Title 4!",
              headline: "Headline 4",
              component: "product-info"
            }
          ]
        };
      },
    });
    
    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    .component-wrapper {
      width: 100%;
    }
    
    .steps-viewport {
      height: calc(100vh - 10rem);
      /* overflow: hidden */
      display: flex;
      flex-direction: column;
    }
    
    .step-wrapper {
      flex: 0 0 calc(100vh - 10rem);
      display: flex;
      justify-content: center;
      flex-direction: column;
    }
    
    .steps-enter-active {
      opacity: 0;
      transform: translateY(100%);
      transition: all 0.4s;
    }
    
    .steps-enter-to {
      opacity: 1;
      transform: translateY(0);
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
    
    <div id="app">
      <div v-for="step in currentStep" :key="step.order" class="step-wrapper">
        <transition-group class="steps-viewport" name="steps" tag="div" appear>
          <h3 class="is-size-5 mb-6 has-text-grey-light" key="1">
            Passo {{ step.order }}
          </h3>
          <h1 class="is-size-3" style="transition-delay: 0.1s" key="2">{{ step.title }}</h1>
          <h2 class="is-size-4 mt-2 has-text-grey" style="transition-delay: 0.2s" key="3">{{ step.headline }}</h2>
        </transition-group>
      </div>
    </div>


    To also transition out, you'll need to use transition instead so you can use mode="out-in" which allows the element to transition out first, before the next one enters.

    You'll also need to target the children of the transitioning element in your CSS with .steps-enter-active > *. Then, just add a .steps-leave-to class which defines the state to leave to:

    new Vue({
      el: '#app',
    
      computed: {
        currentStep() {
          return this.steps.filter(s => s.order === this.activeStep);
        }
      },
    
      methods: {
        nextStep() {
          if (this.activeStep !== this.steps.length) {
            this.activeStep++;
          } else {
            this.activeStep = 1;
          }
        }
      },
    
      data: () => {
        return {
          activeStep: 1,
          steps: [{
              order: 1,
              title: "Title 1?",
              headline: "Headline 1",
              component: "product-info"
            },
            {
              order: 2,
              title: "Title 2",
              headline: "Headline 2.",
              component: "product-info"
            },
            {
              order: 3,
              title: "Title 3",
              headline: "Headline 3.",
              component: "product-info"
            },
            {
              order: 4,
              title: "Title 4!",
              headline: "Headline 4",
              component: "product-info"
            }
          ]
        };
      },
    });
    
    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    .component-wrapper {
      width: 100%;
    }
    
    .steps-viewport {
      height: calc(100vh - 10rem);
      /* overflow: hidden */
      display: flex;
      flex-direction: column;
    }
    
    .step-wrapper {
      flex: 0 0 calc(100vh - 10rem);
      display: flex;
      justify-content: center;
      flex-direction: column;
    }
    
    .step-wrapper,
    .step-wrapper>* {
      transition: all 0.4s;
    }
    
    .step-wrapper>h1 {
      transition-delay: 0.1s;
    }
    
    .step-wrapper>h2 {
      transition-delay: 0.2s;
    }
    
    .steps-enter-active>* {
      opacity: 0;
      transform: translateY(100%);
    }
    
    .steps-leave-to>* {
      opacity: 0;
      transform: translateY(-100%);
    }
    
    .steps-enter-to>* {
      opacity: 1;
      transform: translateY(0);
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
    
    <div id="app">
      <transition name="steps" mode="out-in" duration="600" appear>
        <div v-for="step in currentStep" :key="step.order" class="step-wrapper">
          <h3 class="is-size-5 mb-6 has-text-grey-light">
            Passo {{ step.order }}
          </h3>
          <h1 class="is-size-3">{{ step.title }}</h1>
          <h2 class="is-size-4 mt-2 has-text-grey">{{ step.headline }}</h2>
        </div>
      </transition>
      <button @click="nextStep()">Next</button>
    </div>


    Lastly, to have everything shift up smoothly once a new element is added, you can wrap the initial fields inside a div, wrap the new element in a transition and reduce the height of the first div by the height of the new element.

    You'll also need to transition the height and configure the timings (transition-delay and duration attribute) to match up correctly:

    new Vue({
      el: '#app',
    
      computed: {
        currentStep() {
          return this.steps.filter(s => s.order === this.activeStep);
        }
      },
    
      methods: {
        nextStep() {
          this.$refs.addStep.disabled = false;
          this.extraStep = false;
          this.$refs.addAnotherStep.disabled = false;
          this.anotherExtraStep = false;
    
          if (this.activeStep !== this.steps.length) {
            this.activeStep++;
          } else {
            this.activeStep = 1;
          }
        },
        addStep() {
          const initial = document.querySelector('.step-initial');
          const input = document.querySelector('.step-input');
    
          // 52px = input height + margin + border
          initial.style.maxHeight = initial.offsetHeight - 52 + 'px';
    
          if (!this.extraStep) {
            this.$refs.addStep.disabled = true;
            this.extraStep = true;
          } else {
            this.$refs.addAnotherStep.disabled = true;
            this.anotherExtraStep = true;
          }
        }
      },
    
      data: () => {
        return {
          extraStep: false,
          anotherExtraStep: false,
          activeStep: 1,
          steps: [{
              order: 1,
              title: "Title 1?",
              headline: "Headline 1",
              component: "product-info"
            },
            {
              order: 2,
              title: "Title 2",
              headline: "Headline 2.",
              component: "product-info"
            },
            {
              order: 3,
              title: "Title 3",
              headline: "Headline 3.",
              component: "product-info"
            },
            {
              order: 4,
              title: "Title 4!",
              headline: "Headline 4",
              component: "product-info"
            }
          ]
        };
      },
    });
    
    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    #app {
      position: relative;
      height: calc(300px + 52px);
    }
    
    .component-wrapper {
      width: 100%;
    }
    
    .steps-viewport {
      /* height: calc(100vh - 10rem); */
      /* overflow: hidden */
      display: flex;
      flex-direction: column;
    }
    
    .step-wrapper,
    .step-wrapper * {
      transition: all 0.2s;
    }
    
    .step-wrapper * {
      margin: 0;
    }
    
    .step-initial {
      display: flex;
      justify-content: space-evenly;
      flex-direction: column;
      height: 300px;
      max-height: 300px;
    }
    
    .step-initial *:nth-child(2) {
      transition-delay: 0.05s;
    }
    
    .step-initial *:nth-child(3) {
      transition-delay: 0.1s;
    }
    
    .steps-enter-active .step-initial * {
      opacity: 0;
      transform: translateY(100%);
    }
    
    .steps-leave-to .step-initial *,
    .steps-leave-to .step-input {
      opacity: 0;
      transform: translateY(-100%);
    }
    
    .steps-leave-to .step-input:nth-of-type(2) {
      transition-delay: 0.2s;
    }
    
    .steps-leave-to .step-input:nth-of-type(3) {
      transition-delay: 0.3s;
    }
    
    .steps-enter-to .step-initial * {
      opacity: 1;
      transform: translateY(0);
    }
    
    .step-input {
      margin: 20px 0;
      height: 30px;
    }
    
    .steps-input-enter-active {
      opacity: 0;
      transform: translateY(100%);
    }
    
    .steps-input-leave-to {
      opacity: 0;
      transform: translateY(-100%);
    }
    
    .steps-input-enter-to {
      opacity: 1;
      transform: translateY(0);
    }
    
    .step-btns {
      position: absolute;
      bottom: 10px;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
    
    <div id="app">
      <transition name="steps" mode="out-in" duration="350" appear>
        <div v-for="step in currentStep" :key="step.order" class="step-wrapper">
          <div class="step-initial">
            <h3 class="is-size-5 mb-6 has-text-grey-light">
              Passo {{ step.order }}
            </h3>
            <h1 class="is-size-3">{{ step.title }}</h1>
            <h2 class="is-size-4 mt-2 has-text-grey">{{ step.headline }}</h2>
          </div>
          <transition name="steps-input">
            <div v-if="extraStep" class="step-input">
              <input />
            </div>
          </transition>
          <transition name="steps-input">
            <div v-if="anotherExtraStep" class="step-input">
              <input />
            </div>
          </transition>
        </div>
      </transition>
      <div class="step-btns">
        <button @click="nextStep()">Next</button>
        <button @click="addStep()" ref="addStep">Add Step</button>
        <button @click="addStep()" ref="addAnotherStep">Add Another Step</button>
      </div>
    </div>