Навигация

Transition и TransitionGroup

Интерфейс без анимаций кажется ломаным. Модальное окно, которое появляется мгновенно, список, элементы которого прыгают при сортировке, страница без плавного перехода между состояниями — всё это раздражает пользователя и снижает восприятие качества приложения. Но анимации легко превратить в ад из setTimeout, ручного управления классами и рассинхронизированных DOM-манипуляций.

Во Vue 3 есть два встроенных компонента для декларативных переходов: <Transition> для анимации появления и исчезновения одного элемента или компонента, и <TransitionGroup> для анимации списков — вставки, удаления и перемещения. В статье разберём, как они работают, какие CSS-классы и JS-хуки предоставляют, как анимировать списки и сохранять производительность.

Содержание

  1. Зачем нужны встроенные Transition-компоненты
  2. <Transition>: базовое использование
  3. CSS-классы переходов
  4. Переходы с v-if и <component :is>
  5. JS-хуки <Transition>
  6. <TransitionGroup>
  7. Анимация перемещения элементов
  8. Производительность анимаций
  9. Сравнение с React
  10. Лучшие практики и антипаттерны
  11. Ключевые тезисы для интервью
  12. Заключение
  13. Полезные ссылки

Зачем нужны встроенные Transition-компоненты

Анимация вручную требует решать несколько задач одновременно:

  1. Отследить момент начала и конца изменения. Компонент появился, исчез или сменился на другой.
  2. Применить и убрать CSS-классы в правильный момент. Нельзя просто добавить класс и ждать: браузеру нужен reflow, чтобы пересчитать стили.
  3. Дождаться окончания анимации и удалить элемент из DOM. Если убрать элемент раньше, анимация прервётся; если позже — элемент будет болтаться в DOM и блокировать события.
  4. Обработать прерывание. Пользователь может уйти со страницы или снова переключить состояние до завершения анимации.

<Transition> и <TransitionGroup> берут на себя всю эту логику. Разработчик описывает, как должны выглядать начальное, конечное и промежуточные состояния, а Vue управляет классами, событиями и синхронизацией с DOM.

Что покрывает каждый компонент

КомпонентСценарийПример
<Transition>Один элемент или компонент появляется, исчезает или заменяетсяМодальное окно, toast, переключение вкладок
<TransitionGroup>Список: добавление, удаление, перемещение элементовСписок задач, фильтрованная таблица, чат

<Transition>: базовое использование

<Transition> оборачивает один корневой элемент или компонент. При изменении условия рендера Vue автоматически добавляет и удаляет CSS-классы, соответствующие фазам перехода.

<script setup>
import { ref } from 'vue';

const show = ref(false);
</script>

<template>
  <button @click="show = !show">Переключить</button>

  <Transition name="fade">
    <p v-if="show">Привет, Vue!</p>
  </Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

При нажатии на кнопку:

  1. Элемент появляется: Vue добавляет классы fade-enter-from и fade-enter-active, затем fade-enter-to.
  2. Элемент исчезает: Vue добавляет fade-leave-from и fade-leave-active, затем fade-leave-to, после чего убирает элемент из DOM.

Важное ограничение

<Transition> может анимировать только один корневой узел за раз. Если внутри несколько элементов с v-if/v-else, Vue понимает их как замену одного элемента другим:

<Transition name="fade">
  <button v-if="isEditing" key="save">Сохранить</button>
  <button v-else key="edit">Редактировать</button>
</Transition>

Атрибут key здесь обязателен: без него Vue увидит два одинаковых <button> и решит, что это один и тот же элемент, и анимации не будет.

Режимы переключения

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

РежимПоведение
defaultНовый элемент появляется одновременно с исчезновением старого
out-inСтарый элемент уходит, затем появляется новый
in-outНовый элемент появляется, затем уходит старый
<Transition name="fade" mode="out-in">
  <button v-if="isEditing" key="save">Сохранить</button>
  <button v-else key="edit">Редактировать</button>
</Transition>

out-in чаще всего выглядит аккуратнее, потому что в DOM не бывает двух версий элемента одновременно.


CSS-классы переходов

Каждый переход разбивается на шесть классов. Префикс зависит от значения name на <Transition>; по умолчанию используется v-.

КлассФазаКогда добавляется
v-enter-fromenterНа один кадр перед началом появления
v-enter-activeenterНа всё время появления
v-enter-toenterСо второго кадра до конца появления
v-leave-fromleaveНа один кадр перед началом исчезновения
v-leave-activeleaveНа всё время исчезновения
v-leave-toleaveСо второго кадра до конца исчезновения
<style>
/* Начальное состояние при появлении */
.fade-enter-from {
  opacity: 0;
  transform: translateY(-20px);
}

/* Активная фаза: к чему стремимся и как долго */
.fade-enter-active {
  transition: opacity 0.5s ease, transform 0.5s ease;
}

/* Конечное состояние при появлении */
.fade-enter-to {
  opacity: 1;
  transform: translateY(0);
}

/* Начальное состояние при исчезновении */
.fade-leave-from {
  opacity: 1;
  transform: translateY(0);
}

/* Активная фаза исчезновения */
.fade-leave-active {
  transition: opacity 0.5s ease, transform 0.5s ease;
}

/* Конечное состояние при исчезновении */
.fade-leave-to {
  opacity: 0;
  transform: translateY(-20px);
}
</style>

Имена классов по умолчанию

Если не указать name, используются классы с префиксом v-:

<Transition>
  <p v-if="show">Без имени</p>
</Transition>
.v-enter-active { transition: opacity 0.3s; }
.v-enter-from { opacity: 0; }

Пользовательские имена классов

Иногда нужно подключить готовую CSS-библиотеку, например Animate.css. Для этого <Transition> позволяет явно задать классы:

<Transition
  enter-active-class="animate__animated animate__bounceIn"
  leave-active-class="animate__animated animate__bounceOut"
>
  <p v-if="show">Сторонняя анимация</p>
</Transition>
АтрибутНазначение
enter-from-classНачальное состояние enter
enter-active-classКлассы активной фазы enter
enter-to-classКонечное состояние enter
leave-from-classНачальное состояние leave
leave-active-classКлассы активной фазы leave
leave-to-classКонечное состояние leave

Переходы с v-if и <component :is>

<Transition> прекрасно работает не только с v-if, но и с динамическими компонентами, и с маршрутами.

Переключение компонентов

<script setup>
import { shallowRef } from 'vue';
import TabA from './TabA.vue';
import TabB from './TabB.vue';

const currentTab = shallowRef(TabA);
</script>

<template>
  <div>
    <button @click="currentTab = TabA">A</button>
    <button @click="currentTab = TabB">B</button>

    <Transition name="slide" mode="out-in">
      <component :is="currentTab" />
    </Transition>
  </div>
</template>

<style>
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.slide-enter-from {
  opacity: 0;
  transform: translateX(30px);
}

.slide-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}
</style>

Первоначальное появление

По умолчанию <Transition> не анимирует элемент, который виден с самого начала. Чтобы включить анимацию при первом рендере, используется пропс appear:

<Transition name="fade" appear>
  <p>Появлюсь с анимацией при загрузке</p>
</Transition>

При этом применяются те же классы, но с префиксом appear-: fade-appear-from, fade-appear-active, fade-appear-to. Их можно задать явно через appear-from-class, appear-active-class, appear-to-class.


JS-хуки <Transition>

Иногда CSS-перехода недостаточно: нужно управлять requestAnimationFrame, работать с Web Animations API, GSAP или выполнять логику в конкретный момент перехода. Для этого <Transition> предоставляет JS-хуки.

ХукКогда вызываетсяТипичное использование
@before-enterПеред добавлением enter-классовНачальная подготовка
@enterПосле добавления enter-классовЗапуск JS-анимации
@after-enterПосле завершения enterОчистка
@enter-cancelledЕсли enter был прерванОтмена
@before-leaveПеред добавлением leave-классовПодготовка к уходу
@leaveПосле добавления leave-классовЗапуск JS-анимации
@after-leaveПосле завершения leaveУдаление из DOM завершено
@leave-cancelledЕсли leave был прерванОтмена

Пример с обратным вызовом done

Если JS-хук принимает второй аргумент — done, Vue не будет автоматически определять длительность по CSS, а дождётся вызова done:

<script setup>
import gsap from 'gsap';

function onEnter(el, done) {
  gsap.fromTo(el,
    { opacity: 0, y: 20 },
    { opacity: 1, y: 0, duration: 0.5, onComplete: done }
  );
}

function onLeave(el, done) {
  gsap.to(el, { opacity: 0, y: -20, duration: 0.3, onComplete: done });
}
</script>

<template>
  <Transition @enter="onEnter" @leave="onLeave">
    <div v-if="show">Анимация через GSAP</div>
  </Transition>
</template>

Только JS-переход

Если CSS не нужен вовсе, используется type="animation" или :css="false":

<Transition :css="false" @enter="onEnter" @leave="onLeave">
  <div v-if="show">Только JS</div>
</Transition>

:css="false" отключает попытки Vue автоматически определить длительность по CSS и снижает накладные расходы.

Хуки appear

Если используется appear, есть отдельные хуки: @before-appear, @appear, @after-appear, @appear-cancelled.


<TransitionGroup>

<TransitionGroup> предназначен для списков. В отличие от <Transition>, он не создаёт собственного DOM-узла: он рендерит переданный элемент — обычно <ul>, <ol> или <div> — и анимирует внутренние элементы при их добавлении, удалении и перемещении.

<script setup>
import { ref } from 'vue';

const items = ref([
  { id: 1, text: 'Первый' },
  { id: 2, text: 'Второй' },
  { id: 3, text: 'Третий' },
]);

function addItem() {
  items.value.push({ id: Date.now(), text: `Элемент ${items.value.length + 1}` });
}

function removeItem(id) {
  items.value = items.value.filter(item => item.id !== id);
}
</script>

<template>
  <button @click="addItem">Добавить</button>

  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id" class="item">
      {{ item.text }}
      <button @click="removeItem(item.id)">×</button>
    </li>
  </TransitionGroup>
</template>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.4s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
</style>

Особенности <TransitionGroup>

  1. Требует :key на каждом элементе. Без ключа Vue не сможет отследить, какой элемент добавился, удалился или переместился.
  2. Пропс tag. Определяет, какой DOM-элемент будет корневым. Если не нужен дополнительный узел, используется :tag="null" (или атрибут tag не указывается во Vue 3.2+, рендерится как fragment).
  3. Не использует mode. В списке одновременно могут появляться и исчезать элементы.
  4. Поддерживает те же CSS-классы и JS-хуки, что и <Transition>, плюс классы для перемещения.

Перемещение элементов

Когда элементы меняют позицию в списке, Vue применяет класс v-move:

.list-move {
  transition: transform 0.4s ease;
}

Vue вычисляет старое и новое положение каждого элемента и применяет transform, чтобы плавно сдвинуть их на новые места. Это работает для сортировки, фильтрации, изменения порядка.


Анимация перемещения элементов

Перемещение — самая сложная часть анимации списка. Браузер мгновенно перестраивает DOM, поэтому Vue использует FLIP-технику:

  1. First: запоминает начальное положение элемента.
  2. Last: измеряет положение после изменения DOM.
  3. Invert: применяет transform, чтобы визуально вернуть элемент на старое место.
  4. Play: запускает transition на transform, элемент плавно движется к новому месту.

Пример сортировки с анимацией

<script setup>
import { ref } from 'vue';

const items = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Cherry' },
]);

function shuffle() {
  items.value = [...items.value].sort(() => Math.random() - 0.5);
}
</script>

<template>
  <button @click="shuffle">Перемешать</button>

  <TransitionGroup name="flip" tag="ul">
    <li v-for="item in items" :key="item.id" class="card">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>

<style>
.flip-move {
  transition: transform 0.5s ease;
}

.flip-enter-active,
.flip-leave-active {
  transition: all 0.5s ease;
}

.flip-enter-from,
.flip-leave-to {
  opacity: 0;
  transform: scale(0.9);
}

/* Важно: убираем leaving-элемент из потока, чтобы остальные могли занять его место */
.flip-leave-active {
  position: absolute;
}
</style>

Почему position: absolute для leave

Без position: absolute удаляемый элемент продолжает занимать место в потоке, и соседние элементы не сдвинутся на его место до завершения анимации. position: absolute вырывает элемент из layout, позволяя остальным плавно перетечь через v-move.

Проблема с высотой родителя

Если удаляемый элемент уходит из потока, высота родительского <ul> может резко измениться, что выглядит как скачок. Решения:

  • Анимировать height или max-height самого элемента.
  • Оборачивать элементы в дополнительный контейнер.
  • Использовать JS-библиотеки вроде GSAP Flip, которые умеют анимировать layout.

Производительность анимаций

Анимации — это один из самых быстрых способов сделать интерфейс тяжёлым. Несколько правил помогают держать их плавными.

Анимируйте только композитные свойства

Браузер рисует страницу в несколько этапов: layout, paint, composite. Самый дешёвый этап — composite, который выполняется на GPU.

СвойствоЭтапы
transform, opacityТолько composite
color, background-colorPaint + composite
width, height, top, left, marginLayout + paint + composite
/* Хорошо */
.slide-enter-active {
  transition: transform 0.3s, opacity 0.3s;
}

/* Плохо: вызывает layout */
.slide-enter-active {
  transition: width 0.3s, height 0.3s;
}

Используйте will-change с осторожностью

will-change подсказывает браузеру подготовиться к анимации, но злоупотребление ими приводит к избыточному потреблению памяти:

.slide-enter-active,
.slide-leave-active {
  will-change: transform, opacity;
}

Лучше добавлять will-change только на время активной анимации и убирать после.

Ограничивайте одновременные анимации

Если одновременно анимируется много элементов, особенно на слабых устройствах, появляются пропуски кадров. Стратегии:

  • Анимировать не все элементы сразу, а только видимые во viewport.
  • Использовать v-memo или виртуализацию списков для больших наборов данных.
  • Для массовых перемещений рассмотреть CSS-анимации вместо JS.

Отложенная загрузка анимаций

Тяжёлые JS-анимации, например графики или сложные эффекты, лучше загружать лениво, чтобы не блокировать первоначальный рендер:

<script setup>
import { defineAsyncComponent } from 'vue';

const HeavyAnimation = defineAsyncComponent(() => import('./HeavyAnimation.vue'));
</script>

move-class и кастомизация

<TransitionGroup> позволяет задать класс для перемещения явно:

<TransitionGroup name="list" move-class="list-move-custom">
  ...
</TransitionGroup>

Это удобно, когда класс v-move по умолчанию конфликтует с другими стилями.


Сравнение с React

В React нет встроенного компонента для анимаций переходов. Обычно используются сторонние библиотеки: react-transition-group, framer-motion, react-spring.

АспектVue <Transition>React Transition GroupFramer Motion
ВстроенностьДа, часть Vue 3Сторонняя библиотекаСторонняя библиотека
APIДекларативные CSS-классы и хукиКлассы + колбэкиДекларативные пропсы motion
<TransitionGroup>Встроен<TransitionGroup> из react-transition-group<AnimatePresence> + <Reorder.Group>
Перемещение элементовКласс v-move, FLIPТребует ручной реализацииВстроено через layout animations
JS-анимацииХуки @enter, @leaveКолбэки на lifecycleAPI animate, useAnimation
РазмерВходит в ядро VueНебольшойБольшой, особенно с layout

Vue vs React Transition Group

React Transition Group работает похоже: компоненты <CSSTransition> и <TransitionGroup> добавляют классы на основе состояния. Но он не управляет самим рендером элемента и требует больше шаблонного кода.

import { CSSTransition } from 'react-transition-group';

<CSSTransition in={show} timeout={300} classNames="fade">
  <div>Появляюсь</div>
</CSSTransition>
.fade-enter { opacity: 0; }
.fade-enter-active { opacity: 1; transition: opacity 300ms; }
.fade-exit { opacity: 1; }
.fade-exit-active { opacity: 0; transition: opacity 300ms; }

В Vue нет необходимости передавать in: компонент сам определяет, появился элемент или исчез.

Vue vs Framer Motion

Framer Motion предлагает более высокоуровневый API:

<motion.div
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
  exit={{ opacity: 0 }}
/>

Framer Motion мощнее для сложных gesture-анимаций, shared layout transitions и drag-and-drop. Vue Transition покрывает большинство стандартных задач без дополнительных зависимостей.


Лучшие практики и антипаттерны

Лучшие практики

  1. Используйте transform и opacity.

    Это самые производительные свойства для анимации.

  2. Добавляйте mode="out-in" для взаимозаменяемых элементов.

    Это предотвращает одновременное наличие двух версий элемента в DOM.

  3. Всегда указывайте :key в <TransitionGroup>.

    Без ключа анимация списка работать не будет.

  4. Используйте appear для первоначальной анимации.

    <Transition name="fade" appear>
      <AppHero />
    </Transition>
  5. Для списков добавляйте position: absolute на leaving-элемент.

    Это позволяет остальным элементам плавно занять освободившееся место.

  6. Типизируйте duration при JS-анимациях.

    Если используется done, убедитесь, что он вызывается всегда — иначе Vue не уберёт элемент.

  7. Предпочитайте CSS-анимации JS-анимациям, где это возможно.

    Они работают на отдельном потоке браузера и не блокируют main thread.

Антипаттерны

  1. Анимация layout-свойств без необходимости.

    /* Плохо */
    .bad-enter-active {
      transition: width 0.5s, height 0.5s;
    }

    Такие анимации вызывают layout thrashing.

  2. Отсутствие :key в <TransitionGroup>.

    <!-- Плохо -->
    <TransitionGroup name="list" tag="ul">
      <li v-for="item in items">{{ item }}</li>
    </TransitionGroup>

    Без ключа Vue не сможет отследить изменения.

  3. Забытый вызов done в JS-хуках.

    function onEnter(el, done) {
      gsap.to(el, { opacity: 1, duration: 0.5 });
      // done не вызван — элемент не будет удалён/показан корректно
    }

    Если хук принимает done, Vue ждёт его вызова.

  4. Длинные CSS-переходы на мелких интерактивных элементах.

    Кнопка с transition 1s выглядит вяло. Для микровзаимодействий достаточно 150–300 мс.

  5. Анимация всего списка при каждом обновлении.

    Если данные обновляются часто, анимация каждого элемента создаёт визуальный шум. Используйте v-memo или разделяйте обновляемые и статичные части.

  6. Игнорирование prefers-reduced-motion.

    Для пользователей с вестибулярными нарушениями анимации должны отключаться:

    @media (prefers-reduced-motion: reduce) {
      .fade-enter-active,
      .fade-leave-active {
        transition: none;
      }
    }

Ключевые тезисы для интервью

  1. Во Vue 3 есть два встроенных компонента для анимаций: <Transition> — для одного элемента, <TransitionGroup> — для списков.
  2. <Transition> добавляет CSS-классы enter-from, enter-active, enter-to, leave-from, leave-active, leave-to в зависимости от фазы.
  3. Атрибут name на <Transition> определяет префикс классов; по умолчанию используется v-.
  4. <Transition> может анимировать только один корневой узел; для замены используется mode="out-in" или mode="in-out".
  5. <TransitionGroup> не создаёт свой DOM-узел, требует :key на каждом элементе и поддерживает класс move для анимации перемещений.
  6. Для перемещения элементов Vue использует FLIP-технику через класс v-move.
  7. JS-хуки @enter, @leave и другие позволяют управлять анимацией вручную; если хук принимает done, Vue ждёт его вызова.
  8. Самые производительные анимируемые свойства — transform и opacity, потому что они не вызывают layout.
  9. Для first-render анимации используется пропс appear.
  10. Vue Transition нет прямого встроенного аналога в React; ближайшие аналоги — React Transition Group и Framer Motion.
  11. Важно учитывать prefers-reduced-motion для доступности и не забывать :key в <TransitionGroup>.

Заключение

<Transition> и <TransitionGroup> — это мощный, но при этом простой способ добавить анимации в Vue-приложение. Они решают рутинные задачи — применение классов, синхронизацию с DOM, обработку прерываний — и позволяют разработчику сосредоточиться на визуальном эффекте. Для большинства интерфейсов CSS-переходов с transform и opacity достаточно, а для сложных сценариев есть JS-хуки и интеграция с библиотеками вроде GSAP.

Главное — не увлекаться анимацией ради анимации. Хороший переход помогает пользователю понять, что изменилось в интерфейсе, а плохой — отвлекает и замедляет. Используйте <Transition> для модалок, toasts, переключений; <TransitionGroup> — для списков и изменения порядка; и всегда проверяйте производительность на реальных устройствах.


Полезные ссылки