Перейти к содержимому

18. Переходы и анимации

Vue имеет встроенную систему анимаций прямо из коробки. Не нужно ничего устанавливать для базовых переходов — просто оберни элемент в <Transition> и добавь CSS классы. Для продвинутых анимаций подключаем GSAP или vueuse/motion 🚀


<Transition> — это встроенный Vue-компонент для анимации одного элемента при появлении/исчезновении:

<template>
<button @click="show = !show">Показать/скрыть</button>
<Transition>
<p v-if="show">Привет, анимация!</p>
</Transition>
</template>
<style>
/* Vue добавляет эти классы автоматически */
/* 1. Начало появления и конец исчезновения */
.v-enter-from,
.v-leave-to {
opacity: 0;
}
/* 2. Активная фаза переходов */
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
/* 3. Конец появления и начало исчезновения */
.v-enter-to,
.v-leave-from {
opacity: 1;
}
</style>
<Transition name="slide-fade">
<div v-if="show">Элемент</div>
</Transition>
<style>
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>

ПОЯВЛЕНИЕ (enter):
enter-from → enter-active → enter-to
(начало) (весь процесс) (конец)
ИСЧЕЗНОВЕНИЕ (leave):
leave-from → leave-active → leave-to
(начало) (весь процесс) (конец)
/* Bounce эффект */
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.25); }
100% { transform: scale(1); }
}

Когда один элемент заменяет другой, важно управлять порядком анимаций:

<!-- Без mode: оба анимируются одновременно (часто выглядит плохо) -->
<Transition>
<span v-if="state === 'on'">ВКЛ</span>
<span v-else>ВЫКЛ</span>
</Transition>
<!-- out-in: сначала уходит старый, потом появляется новый -->
<Transition name="fade" mode="out-in">
<component :is="currentComponent" :key="currentComponent" />
</Transition>
<!-- in-out: сначала появляется новый, потом уходит старый -->
<Transition name="slide" mode="in-out">
<div :key="page">{{ page }}</div>
</Transition>

Для GSAP и других библиотек используй JavaScript хуки:

<script setup lang="ts">
import { ref } from 'vue'
import gsap from 'gsap'
function onBeforeEnter(el: HTMLElement) {
el.style.opacity = '0'
el.style.height = '0'
}
function onEnter(el: HTMLElement, done: () => void) {
gsap.to(el, {
opacity: 1,
height: 'auto',
duration: 0.5,
ease: 'power2.out',
onComplete: done, // Обязательно вызвать done!
})
}
function onLeave(el: HTMLElement, done: () => void) {
gsap.to(el, {
opacity: 0,
height: 0,
duration: 0.4,
ease: 'power2.in',
onComplete: done,
})
}
</script>
<template>
<Transition
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<div v-if="show">Анимированный контент</div>
</Transition>
</template>
<Transition
@before-enter="onBeforeEnter" <!-- До добавления в DOM -->
@enter="onEnter" <!-- Начало появления -->
@after-enter="onAfterEnter" <!-- Появление завершено -->
@enter-cancelled="onEnterCancelled" <!-- Появление прервано -->
@before-leave="onBeforeLeave" <!-- До начала исчезновения -->
@leave="onLeave" <!-- Начало исчезновения -->
@after-leave="onAfterLeave" <!-- Исчезновение завершено -->
@leave-cancelled="onLeaveCancelled"
>

<TransitionGroup> анимирует добавление, удаление и перемещение элементов списка:

<script setup lang="ts">
import { ref } from 'vue'
const items = ref([1, 2, 3, 4, 5])
function add() {
items.value.push(items.value.length + 1)
}
function remove(id: number) {
items.value = items.value.filter(i => i !== id)
}
function shuffle() {
items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>
<template>
<button @click="add">Добавить</button>
<button @click="shuffle">Перемешать</button>
<!-- Важно: tag="ul" — указываем что рендерить как обёртку -->
<!-- По умолчанию — Fragment (нет обёртки) -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item">
{{ item }}
<button @click="remove(item)">✕</button>
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* Магия! FLIP анимация при перемещении элементов */
.list-move {
transition: transform 0.5s ease;
}
/* Пока элемент удаляется — выводим его из потока */
.list-leave-active {
position: absolute;
}
</style>

Окно терминала
npm install gsap
<!-- Staggered list с GSAP -->
<script setup lang="ts">
import { ref } from 'vue'
import gsap from 'gsap'
interface ListItem {
id: number
text: string
}
function onBeforeEnter(el: HTMLElement) {
el.style.opacity = '0'
el.style.transform = 'translateY(-30px)'
}
function onEnter(el: HTMLElement, done: () => void) {
gsap.to(el, {
opacity: 1,
y: 0,
duration: 0.4,
// Stagger через data-index
delay: Number(el.dataset.index) * 0.1,
onComplete: done,
})
}
function onLeave(el: HTMLElement, done: () => void) {
gsap.to(el, {
opacity: 0,
x: 100,
duration: 0.3,
delay: Number(el.dataset.index) * 0.05,
onComplete: done,
})
}
</script>
<template>
<TransitionGroup
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<div
v-for="(item, index) in items"
:key="item.id"
:data-index="index"
>
{{ item.text }}
</div>
</TransitionGroup>
</template>

App.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<RouterView v-slot="{ Component, route: r }">
<Transition
:name="r.meta.transition as string || 'fade'"
mode="out-in"
>
<component :is="Component" :key="r.path" />
</Transition>
</RouterView>
</template>
<style>
/* Fade */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Slide right */
.slide-right-enter-active, .slide-right-leave-active {
transition: transform 0.35s ease, opacity 0.35s ease;
}
.slide-right-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-right-leave-to {
transform: translateX(30px);
opacity: 0;
}
/* Scale */
.scale-enter-active, .scale-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.scale-enter-from, .scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
</style>

Окно терминала
npm install @vueuse/motion
main.ts
import { MotionPlugin } from '@vueuse/motion'
app.use(MotionPlugin)
<!-- v-motion директива -->
<template>
<!-- Базовая анимация появления -->
<div
v-motion
:initial="{ opacity: 0, y: 100 }"
:enter="{ opacity: 1, y: 0, transition: { duration: 500 } }"
>
Я появлюсь снизу!
</div>
<!-- Hover анимация -->
<button
v-motion
:initial="{ scale: 1 }"
:hovered="{ scale: 1.05 }"
:pressed="{ scale: 0.95 }"
>
Наведи на меня!
</button>
<!-- Пресеты -->
<div v-motion-fade>Fade появление</div>
<div v-motion-slide-bottom>Появление снизу</div>
<div v-motion-roll-visible-bottom>Появление при скролле</div>
<div v-motion-pop>Pop эффект</div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTransition, TransitionPresets } from '@vueuse/core'
const targetValue = ref(0)
// Анимируем число
const animatedValue = useTransition(targetValue, {
duration: 800,
transition: TransitionPresets.easeOutCubic,
})
</script>
<template>
<input type="range" v-model="targetValue" min="0" max="100" />
<p>{{ Math.round(animatedValue) }}%</p>
</template>

<Transition> — один элемент, v-if/v-show
<TransitionGroup> — список элементов, v-for
Классы:
.name-enter-from — начало появления
.name-enter-active — весь процесс появления
.name-enter-to — конец появления
.name-leave-from — начало исчезновения
.name-leave-active — весь процесс исчезновения
.name-leave-to — конец исчезновения
.name-move — перемещение (TransitionGroup)
mode="out-in" — сначала старый уходит, потом новый приходит
mode="in-in" — сначала новый приходит, потом старый уходит
:css="false" — отключить CSS классы, использовать только JS хуки
appear — анимировать при первом рендере