View Transitions и жесты: снапшоты, пружины и перехват значений на лету
document.startViewTransition снимает старое состояние, колбэк мутирует DOM (в React — flushSync), псевдоэлементы ::view-transition анимируют старое к новому. Жесты: pointer capture, transform через rAF, никаких чтений layout в move-обработчиках, перехват значений на лету.
Нижний шит приложения доставки еды был флагманским взаимодействием — и худшим по отзывам. На iPhone он следовал за пальцем; на среднем Android он полз на 12 fps и отставал от большого пальца на видимые полсекунды. Постмортем нашёл два греха в одном обработчике. Первый: шит тащили, анимируя height, и каждый pointermove читал content.offsetHeight сразу после записи новой высоты — форсированный синхронный layout на событие, а pointermove стреляет с частотой ввода, часто два-три раза на кадр на 120-Гц дигитайзере. Математика: три форсированных layout по 5–8 мс каждый, на кадр, на термотроттленном SoC — 12 fps были не загадкой, а арифметикой. Второй грех: когда пружинная анимация доводила шит до открытого состояния и пользователь хватал его снова, код ждал animation.finished, прежде чем снова включить перетаскивание, — 300 мс шит игнорировал палец, физически лежащий на нём, что пользователи надёжно описывали словом «сломано», а не «медленно». Переписали так: измерять один раз на pointerdown, двигать весь шит через transform, батчировать записи через rAF, а при новом захвате читать текущий трансформ, отменять пружину и продолжать ровно оттуда. Тот же телефон, 60 fps, и отзывы перестали говорить «сломано». У прямой манипуляции один закон: значением владеет палец — никогда не заставляйте палец ждать.
startViewTransition: машина снапшотов
До появления этого API анимация между двумя принципиально разными состояниями страницы требовала удерживать старый и новый DOM живыми одновременно — та самая сложность в духе AnimatePresence, из-за которой анимационный код становился хрупким. document.startViewTransition(callback) решает это иначе: он выполняет фиксированный протокол. Браузер захватывает старое состояние — статический снимок страницы плюс отдельный снимок каждого элемента с view-transition-name. Затем выполняет ваш колбэк, который обязан реально мутировать DOM (и может вернуть promise, который браузер дождётся). Когда колбэк завершился, браузер захватывает новое состояние, строит дерево псевдоэлементов поверх страницы — ::view-transition в корне, и на каждое имя ::view-transition-group, содержащий ::view-transition-image-pair с ::view-transition-old (статический битмап) и ::view-transition-new (живое представление нового контента) — и анимирует между ними. По умолчанию — кроссфейд для корня плюс интерполяция позиции и размера для каждой именованной группы; всё настраивается обычными CSS-анимациями на псевдоэлементы, потому что это всё, чем они являются: элементы, которыми владеет браузер и которые стилизуете вы.
Контракт именования важен: view-transition-name обязан быть уникальным среди захваченных элементов в каждом состоянии — дубликат заставляет переход пропуститься (promise ready отклоняется), классический баг, когда все карточки списка получают view-transition-name: card из одного CSS-правила. И API по умолчанию работает в пределах документа; кросс-документные MPA-переходы включаются CSS-правилом @view-transition { navigation: auto; } на обеих same-origin-страницах — именно это позволяет снапшотным переходам работать, даже когда старый документ уничтожается посреди навигации.
Шов React: flushSync — или видимых изменений не будет
Браузер снимает «новое», когда колбэк завершился. Обновления состояния React ставятся в очередь, а не выполняются синхронно — отсюда канонический баг интеграции:
import { flushSync } from "react-dom";
function navigate(next) {
if (!document.startViewTransition) {
setRoute(next); // мягкая деградация: без анимации
return;
}
document.startViewTransition(() => {
// DOM обязан быть в НОВОМ состоянии, когда этот колбэк вернётся.
// Голый setRoute(next) ставит обновление в очередь: колбэк возвращается
// с неизменённым DOM, браузер снимает old === new, а настоящий коммит
// приходит ПОСЛЕ перехода — фейд в тот же кадр, затем скачок.
flushSync(() => setRoute(next));
});
}flushSync заставляет React отрендерить и закоммитить синхронно внутри колбэка — ровно то место, где цена синхронного коммита — суть, а не запах. Компонент <ViewTransition> в React 19 оборачивает эту машинерию декларативно: активируется на переходах, запущенных через startTransition, интегрируется с раскрытиями Suspense и сшивает общие элементы по имени. Честный статус: он живёт только в экспериментальном канале (react@experimental), не в стабильном React 19 — на стабильном канале сегодня продакшен-паттерн — это шов startViewTransition + flushSync выше, и он достаточно мал, чтобы владеть им самим.
document.startViewTransition(() => setRoute(next)) даёт короткий фейд в идентичный кадр, после чего новая страница появляется скачком без анимации. Что произошло?
Жесты: одна модель указателя, три дисциплины
События указателя объединили мышь, тач и перо в один поток: pointerdown, pointermove, pointerup, каждое несёт pointerId. Структурный инструмент — setPointerCapture(pointerId): элемент продолжает получать поток, даже когда палец покидает его границы — без бухгалтерии слушателей на document, без потерянных перетаскиваний, когда указатель обгоняет шит. Вокруг этого — три дисциплины, отделяющие 60 fps от 12 из Hook:
function onPointerDown(e) {
sheet.setPointerCapture(e.pointerId);
running.current?.cancel(); // перехват: убить пружину на лету
const m = new DOMMatrixReadOnly(getComputedStyle(sheet).transform);
grabOffset.current = m.m42 - e.clientY; // продолжить от ТЕКУЩЕГО значения
maxTravel.current = sheet.offsetHeight; // читать layout ОДИН раз, здесь
}
function onPointerMove(e) {
latestY.current = e.clientY; // только сохранить — никаких чтений layout
raf.current ??= requestAnimationFrame(() => {
raf.current = null; // одна запись на кадр, не на событие
sheet.style.transform = `translateY(${clamp(latestY.current + grabOffset.current)}px)`;
});
}Дисциплина первая — батчируйте записи через rAF. pointermove стреляет с частотой ввода, которая на современных дигитайзерах превышает частоту кадров; запись стилей на каждое событие — невидимая дублирующая работа, а время обработчика напрямую задерживает следующий кадр. Сохраняйте последнюю позицию; пишите раз на кадр. Дисциплина вторая — никаких чтений layout в move-обработчиках. offsetHeight на каждый move из Hook — форсированный синхронный layout, умноженный на частоту событий; измеряйте всё на pointerdown и кэшируйте. Дисциплина третья — объявляйте намерение браузеру. touch-action: none на ручке перетаскивания говорит браузеру, что жест ваш, и он не выжидает, не имели ли вы в виду скролл; везде остальном оставьте скролл компоновщику и держите слушатели passive, чтобы он там и оставался.
Отпускание и прерывание: значением владеет палец
На pointerup вычислите скорость отпускания по последним move-сэмплам и передайте значение пружине или инерции — WAAPI со сгенерированными keyframes или rAF-пружина, если нужно живое перенацеливание. Жёсткое правило — прерывание: новый pointerdown во время анимации обязан перехватить значение на лету — прочитать текущий трансформ (через getComputedStyle и DOMMatrixReadOnly или из собственного состояния пружины), отменить анимацию и начать перетаскивание ровно с этого значения. Обе соблазнительные альтернативы читаются как дефекты: ожидание finished заставляет поверхность игнорировать касающийся палец («сломано» из Hook), а старт от цели анимации телепортирует шит под большим пальцем пользователя. Прямая манипуляция переворачивает обычный контракт UI: для кнопок ввод — это запрос; для перетаскиваемой поверхности ввод — это владение.
Нижний шит на полпути пружинит к открытому состоянию, когда пользователь хватает его снова. Код делает running.finished.then(enableDrag), и шит игнорирует захват ~300 мс. Какова корректная модель прерывания?
Тач-бюджеты: постмортем 12 fps в цифрах
Прокрутим Hook с числами. pointermove ~120 событий/с на дигитайзере, ~2 события на кадр при 60 Гц. Каждый обработчик: запись height (инвалидирует layout), затем чтение offsetHeight — форсированный синхронный layout, 5–8 мс на среднем SoC. Два события на кадр ≈ 10–16 мс форсированного layout, плюс style/paint, которые пачкает само изменение высоты, ≈ бюджет сорван каждый кадр: 12 fps. Исправленный обработчик: одна rAF-батченная запись transform на кадр (микросекунды), ноль чтений layout после pointerdown, touch-action: none, чтобы браузер не гадал о намерении скролла. То же железо, 60 fps — дело никогда не было в телефоне. Две финальные заметки: getCoalescedEvents() отдаёт полный след слитых move-событий, если нужна чернильная точность без обработчика на каждое событие; и prefers-reduced-motion распространяется и на анимации отпускания жестов — снап вместо пружины для тех, кто это попросил.
▸Почему это работает
Почему снимки, а не анимация живого DOM? Потому что состояния, между которыми анимируют, часто никогда не сосуществуют. Смена маршрута размонтирует старое поддерево в том же коммите, где монтируется новое — нет момента, когда и старая карточка, и новый детальный экран есть в DOM, чтобы их твинить, а принуждение их к сосуществованию — ровно та сложность в духе AnimatePresence, которую платформа хотела убрать. Битмап старого состояния переживает удаление породившего его DOM; именно это заставляет API работать для произвольных мутаций и делает MPA-вариант возможным вообще — старый документ может быть полностью снесён, пока его пиксели анимируются. Цена сделки: снимки заморожены — старая сторона не продолжит играть видео или переносить текст посреди перехода, — а страница ненадолго накрыта браузерными псевдоэлементами, поэтому длинные переходы ощущаются потерей интерактивности, и 200–350 мс — практический потолок.
- 01Проследите document.startViewTransition от и до для смены маршрута в React, включая дерево псевдоэлементов и два классических бага интеграции.
- 02Перечислите дисциплины жестов для перетаскиваемой поверхности в 60 fps и протокол прерывания — с арифметикой, осуждающей чтения layout в move-обработчиках.
View Transitions переносят трудную часть анимации смены состояния в браузер: startViewTransition захватывает статический снимок старого состояния — по view-transition-name и для корня, — выполняет колбэк, захватывает новое состояние и анимирует между ними в браузерном дереве псевдоэлементов (::view-transition-group, image-pair, old, new), которое вы настраиваете обычным CSS. Шов React — одна строка с острой кромкой: DOM обязан быть закоммичен к завершению колбэка, поэтому setState идёт через flushSync — без него браузер снимает неизменённый DOM, анимирует старое к старому, и коммит появляется скачком после. Имена уникальны в пределах захваченного состояния, иначе переход пропускается; MPA-переходы включаются правилом @view-transition и работают потому, что снимки переживают породившие их документы — то же свойство, благодаря которому вся конструкция работает для мутаций, чьи «до» и «после» никогда не сосуществуют в DOM. Компонент ViewTransition в React 19 упаковывает это для переходов и Suspense — только в экспериментальном канале; на стабильном продакшен-паттерн — шов flushSync. Жесты — половина взаимодействия, которую машина снимков не покрывает, и они держатся на трёх дисциплинах плюс одном законе. Дисциплины: батчируйте записи transform через rAF, потому что pointermove обгоняет кадры; измеряйте на pointerdown и никогда не читайте layout в move-обработчике — два форсированных layout на кадр по 5–8 мс каждый и были всем постмортемом 12 fps; объявляйте намерение через touch-action и passive-слушатели, чтобы скролл оставался на компоновщике. Закон — владение: палец на поверхности владеет анимируемым значением, поэтому новый захват читает текущий трансформ, отменяет пружину и продолжает ровно оттуда — ожидание finished оскорбляет пользователя, а прыжок к цели рвёт ту самую связь пальца с пикселями, которой прямая манипуляция и является. Теперь, когда увидишь перетаскиваемую поверхность, запаздывающую за пальцем, — проверяй чтения layout в move-обработчике; а когда смена маршрута не анимируется — проверяй, был ли DOM реально закоммичен до второго снимка.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.