Генератор View Transitions

View Transitions API анимирует смену содержимого страницы: элементы плавно переезжают на новые места, карточка вырастает в подробности, тема меняется кругом от точки клика. Выберите сценарий, настройте эффект и скопируйте готовые CSS и JavaScript.

Как пользоваться

Выберите пример в галерее или сценарий в настройках, затем меняйте содержимое кнопками в предпросмотре: переставляйте и добавляйте элементы списка, переключайте сетку в список, фильтруйте элементы, открывайте карточки, меняйте тему и вкладки. Эффект, длительность и плавность применяются сразу. «Замедлить в 4 раза» помогает рассмотреть, что происходит со снимками.

Готовый код состоит из двух частей: CSS настраивает анимацию, а JavaScript меняет DOM внутри document.startViewTransition(). «Скачать пример HTML» сохраняет рабочую страницу со всем кодом. Ссылка «Поделиться» сохраняет настройки.

Как работает переход

Перед изменением браузер делает снимок страницы, затем вызывает вашу функцию, меняющую DOM, и делает второй снимок. Сверху страницы появляется дерево псевдоэлементов: ::view-transition-old() — картинка «до», ::view-transition-new() — живое изображение «после», ::view-transition-group() — их общая рамка. По умолчанию старый снимок растворяется, а новый проявляется; CSS-анимации этих псевдоэлементов задают любой другой эффект.

Элемент со свойством view-transition-name снимается отдельно. Если в обоих состояниях есть элемент с одним именем, браузер плавно переносит его из старого положения и размера в новое — даже если это разные элементы DOM, как карточка и блок подробностей. Имя в каждый момент должно быть уникальным.

В отличие от transition и CSS-анимаций, здесь не нужно знать, какие свойства меняются: переход строится из снимков, поэтому годится для перестановки, добавления и удаления элементов и полной замены содержимого.

Современные возможности

view-transition-name: match-element даёт каждому элементу уникальное имя автоматически — не нужно нумеровать элементы списка скриптом. Псевдокласс :only-child у снимка выбирает элементы без пары: только что добавленные или удалённые.

Типы переходов. startViewTransition({ update, types: ['backward'] }) отмечает переход типом, а селектор :active-view-transition-type(backward) выбирает для него свою анимацию — так вкладки уезжают в нужную сторону.

Переход между страницами. Правило @view-transition { navigation: auto; } на двух страницах одного origin (совпадают протокол, домен и порт) анимирует обычный переход по ссылке — без JavaScript и без одностраничного приложения. В режиме «Переход между страницами» откройте живую демонстрацию в новой вкладке. Кнопки скачивания сохраняют index.html и detail.html: положите их в одну папку и откройте через HTTP(S)-сервер. match-element не связывает элементы разных документов: задайте им одинаковые явные имена.

Пока идёт переход, страница не реагирует на клики, поэтому держите его коротким. Для людей, отключивших движение в системе, анимацию снимает медиазапрос prefers-reduced-motion. Браузеры без поддержки просто применяют изменения сразу — помощник withTransition в готовом коде учитывает это.

Что сравнить в примерах
  • «Сетка превращается в список»: меняется раскладка, а имена связывают те же элементы до и после.
  • «Фильтрация элементов»: оставшиеся элементы переезжают, скрытые исчезают; повторный клик возвращает их.
  • «Список с ручными именами»: стабильное имя относится к элементу, а не к его текущему месту.
  • «Плавная смена темы» и «Тема кругом от клика»: одинаковое изменение DOM с разными анимациями снимков.

Описание API и дополнительные примеры: руководство MDN и галерея MDN.

Практические сценарии и готовый проект

Поиск и категории помогают выбрать пример. В предпросмотре есть пояснение к текущему сценарию, замедление, пропуск анимации и «Начать заново». Перезапуск сбрасывает только демонстрацию, сохраняя настройки эффекта.

  • Аккордеон: размеры раскрытого раздела определяются содержимым, соседние разделы плавно сдвигаются.
  • Избранное: тот же элемент перемещается между списками, сохраняя имя и фокус.
  • Навигация: открытие сообщения и возврат используют разные направления.
  • Составной переход: иллюстрация, заголовок и описание имеют отдельные имена и задержки.
  • Фотогалерея: изображение и подпись меняются разными снимками; иллюстрации включены в HTML.
  • Видео: запустите локальный деморолик или выберите свой файл. Изменение размера не пересоздаёт проигрыватель; файл никуда не отправляется.

Вкладки CSS и JavaScript показывают код перехода; «HTML целиком» содержит рабочую страницу с разметкой и оформлением. Новые практические сценарии исполняют тот же код в изолированном предпросмотре. Настройки передаются в него без пересоздания содержимого.

Идеи сценариев: демонстрации Chrome DevRel. Учебные версии здесь адаптированы к инструменту и работают без внешних библиотек.

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

Предпросмотр

Переставляйте, добавляйте и удаляйте элементы. Стабильное имя связывает элемент с его новым местом.

Настройка перехода

Сценарий
Имена элементов списка

Подсказки

    Готовый код

    Включите JavaScript, чтобы увидеть переходы в предпросмотре и настроить код.

    CSS

    .item {
      view-transition-name: match-element;
    }
    
    ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
      animation-duration: 0.35s;
      animation-timing-function: ease;
    }
    
    ::view-transition-old(*):only-child {
      animation-name: vt-scale-out;
    }
    
    ::view-transition-new(*):only-child {
      animation-name: vt-scale-in;
    }
    
    @keyframes vt-scale-out {
      to { transform: scale(0.9); opacity: 0; }
    }
    
    @keyframes vt-scale-in {
      from { transform: scale(1.1); opacity: 0; }
    }
    
    @media (prefers-reduced-motion: reduce) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) {
        animation: none !important;
      }
    }

    JavaScript

    // Без API или при reduced motion обновление остаётся рабочим.
    let activeTransition;
    function withTransition(update, types) {
      const reduceMotion = document.documentElement.dataset.reduceMotion ?? 'true';
      if (!document.startViewTransition || (reduceMotion === 'true' && matchMedia('(prefers-reduced-motion: reduce)').matches)) {
        update();
        return;
      }
      activeTransition?.skipTransition();
      // Проверяем поддержку типов отдельно от базового API.
      const supportsTypes = CSS.supports('selector(:active-view-transition-type(backward))');
      const transition = document.startViewTransition(types && supportsTypes ? { update, types } : update);
      activeTransition = transition;
      transition.ready.catch(() => {}); // Пропущенная анимация не отменяет обновление DOM.
      transition.finished.then(() => {
        if (activeTransition === transition) { activeTransition = null; }
      }, () => { activeTransition = null; });
    }
    
    const list = document.querySelector('.list');
    let nextId = list.children.length;
    function nameItems() {
      // Автоматические имена заменяем стабильными ручными, если match-element не поддерживается.
      list.querySelectorAll('.item').forEach((item, index) => {
        if (!item.dataset.id) { item.dataset.id = String(index); }
        if (!CSS.supports('view-transition-name', 'match-element')) {
          item.style.viewTransitionName = 'item-' + item.dataset.id;
        }
      });
    }
    nameItems();
    
    document.querySelector('.shuffle').addEventListener('click', () => {
      withTransition(() => list.prepend(list.lastElementChild));
    });
    document.querySelector('.add').addEventListener('click', () => {
      if (list.children.length >= 10) { return; }
      withTransition(() => {
        const item = document.createElement('li');
        item.className = 'item';
        item.dataset.id = String(nextId++);
        item.textContent = String.fromCharCode(65 + Number(item.dataset.id) % 26);
        list.append(item);
        nameItems();
      });
    });
    document.querySelector('.remove').addEventListener('click', () => {
      if (list.children.length > 1) {
        withTransition(() => list.lastElementChild.remove());
      }
    });

    Самодостаточный HTML

    <!doctype html>
    <html lang="ru">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Пример View Transitions</title>
    <style>
    body { margin: 0; padding: 40px; font: 16px/1.5 system-ui, sans-serif; background: #f3f5f9; color: #182238; }
    html.dark body { background: #182238; color: #edf0f5; }
    .list { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; list-style: none; }
    .item, .card { display: grid; place-items: center; width: 80px; height: 80px; border: 0; border-radius: 12px; background: #3f6fd8; color: #fff; font-size: 24px; }
    .cards { display: flex; flex-wrap: wrap; gap: 12px; }
    .list.rows { flex-direction: column; }
    .list.rows .item { width: min(280px, 100%); height: 48px; }
    [hidden] { display: none !important; }
    .detail, .panel { max-width: 480px; padding: 24px; border-radius: 16px; background: #3f6fd8; color: #fff; }
    
    .item {
      view-transition-name: match-element;
    }
    
    ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
      animation-duration: 0.35s;
      animation-timing-function: ease;
    }
    
    ::view-transition-old(*):only-child {
      animation-name: vt-scale-out;
    }
    
    ::view-transition-new(*):only-child {
      animation-name: vt-scale-in;
    }
    
    @keyframes vt-scale-out {
      to { transform: scale(0.9); opacity: 0; }
    }
    
    @keyframes vt-scale-in {
      from { transform: scale(1.1); opacity: 0; }
    }
    
    @media (prefers-reduced-motion: reduce) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) {
        animation: none !important;
      }
    }
    </style>
    </head>
    <body>
    <button class="shuffle" type="button">Переставить</button>
    <button class="add" type="button">Добавить</button>
    <button class="remove" type="button">Удалить</button>
    <ul class="list"><li class="item">A</li><li class="item">B</li><li class="item">C</li><li class="item">D</li><li class="item">E</li></ul>
    <script>
    // Без API или при reduced motion обновление остаётся рабочим.
    let activeTransition;
    function withTransition(update, types) {
      const reduceMotion = document.documentElement.dataset.reduceMotion ?? 'true';
      if (!document.startViewTransition || (reduceMotion === 'true' && matchMedia('(prefers-reduced-motion: reduce)').matches)) {
        update();
        return;
      }
      activeTransition?.skipTransition();
      // Проверяем поддержку типов отдельно от базового API.
      const supportsTypes = CSS.supports('selector(:active-view-transition-type(backward))');
      const transition = document.startViewTransition(types && supportsTypes ? { update, types } : update);
      activeTransition = transition;
      transition.ready.catch(() => {}); // Пропущенная анимация не отменяет обновление DOM.
      transition.finished.then(() => {
        if (activeTransition === transition) { activeTransition = null; }
      }, () => { activeTransition = null; });
    }
    
    const list = document.querySelector('.list');
    let nextId = list.children.length;
    function nameItems() {
      // Автоматические имена заменяем стабильными ручными, если match-element не поддерживается.
      list.querySelectorAll('.item').forEach((item, index) => {
        if (!item.dataset.id) { item.dataset.id = String(index); }
        if (!CSS.supports('view-transition-name', 'match-element')) {
          item.style.viewTransitionName = 'item-' + item.dataset.id;
        }
      });
    }
    nameItems();
    
    document.querySelector('.shuffle').addEventListener('click', () => {
      withTransition(() => list.prepend(list.lastElementChild));
    });
    document.querySelector('.add').addEventListener('click', () => {
      if (list.children.length >= 10) { return; }
      withTransition(() => {
        const item = document.createElement('li');
        item.className = 'item';
        item.dataset.id = String(nextId++);
        item.textContent = String.fromCharCode(65 + Number(item.dataset.id) % 26);
        list.append(item);
        nameItems();
      });
    });
    document.querySelector('.remove').addEventListener('click', () => {
      if (list.children.length > 1) {
        withTransition(() => list.lastElementChild.remove());
      }
    });
    </script>
    </body>
    </html>
    

    Плавность перехода можно подобрать в редакторе cubic-bezier, а анимацию отдельных свойств — в генераторе transition.