Анимация в React: исчезновение, раскладка и Motion
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Уведомление «Сохранено» можно проявить плавно, а убрать так же плавно — нет, и стилями этого не исправить. React снимает узел со страницы в коммите, одним шагом, и переходу не из чего рисовать путь: у элемента, которого больше нет, не бывает «после». Поэтому уход в React — не стиль, а состояние: узел должен оставаться в разметке, пока анимация не доиграет. Соберём это сами — сначала через element.animate, потом фазой ухода — и узнаем ту же идею в AnimatePresence из библиотеки Motion. Вторая половина статьи — о раскладке: как сделать, чтобы соседи не прыгали на новые места, а ехали, и что делает проп layout.
Появление уведомления CSS умеет: у нового элемента нет «было», и его задаёт @starting-style. С исчезновением иначе. Переход рисует путь между двумя состояниями элемента, который остаётся на странице, а в таблице «Где CSS кончается» строка «исчезновение при удалении» так и помечена: убранный элемент пропадает сразу, анимировать нечего. У React на это своя причина. Разницу между старым и новым описанием вносит в DOM коммит, одним заходом: нет тоста в описании — после коммита нет и узла.
Может, поймать узел на выходе? Ниже у тоста две уборки — useLayoutEffect и useEffect, — и обе спрашивают, на странице ли узел. Переход по opacity в стиле тоже есть. «Закрыть» нажимается через 50 мс, через 100 мс пример считает тосты. Предскажите три строки.
Уборка layout-эффекта застала узел на странице, уборка обычного эффекта — уже нет, а через 100 мс тостов ноль. Переход из стиля ничего не дал: opacity никто не менял, узел просто исчез. Порядок уборок и снятия — то, что показывает пример на React 19.2.3 песочницы, а не обещание документации. Важнее другое: отложить снятие не может ни одна уборка. Коммит выполняется целиком, и кадра, где тост был бы «уже уходящим», браузер не нарисует.
Уборка layout-эффекта видит узел — значит, в ней можно поставить opacity: 0 и запустить переход, пока не поздно. Строка с getComputedStyle — «было» для перехода: браузер должен посчитать стиль тоста до правки, как первый замер перед щелчком в стоп-кадре пролога. Предскажите обе строки.
Переход создан: getAnimations() нашёл один. Через 100 мс нет ни узла, ни перехода — React снял узел в том же коммите, сразу за уборкой, и переход погиб вместе с ним. Без строки с getComputedStyle первая строка зависела бы от того, видна ли рамка: у рамки за краем экрана браузер ни разу не считал стиль тоста, и переходов выходило 0 — переходить было не из чего. Уборка ref-функции видит то же, что layout-эффект: узел ещё на странице — и так же ничего не может отложить.
Вывод раздела: «показан» и «есть в DOM» — два разных факта. Чтобы уход был виден, узел должен оставаться в описании, пока анимация не кончится. Дальше — два способа устроить это руками и библиотека, которая делает это за вас.
CSS в примерах записан строкой в теге style прямо в JSX, чтобы стили были видны рядом с кодом. В проекте они живут в CSS-модуле.
Первый способ — не менять состояние, пока анимация не доиграла. Анимацию можно запустить из JavaScript: у каждого элемента есть метод animate(кадры, настройки) из Web Animations API. Кадры — массив объектов со стилями, настройки — длительность и прочее, как у animation в CSS. Метод возвращает объект Animation: у него есть состояние playState и обещание finished, которое выполнится, когда анимация доиграет. Это Baseline widely с 16.03.2023. В JSX анимации нет, поэтому трогать её через ref законно — по правилу через ref трогают то, чего React не описывает. Обработчик «закрыть» запускает анимацию, ждёт finished и только потом зовёт сеттер. Предскажите порядок четырёх строк.
Клик застаёт анимацию в состоянии running, и обработчик ждёт. Когда finished выполнилось, узел ещё на странице и уже прозрачен: fill: "forwards" держит последний кадр, как forwards у animation. Только после этого setOpen(false) заказывает коммит, и тот снимает невидимый узел — исчезновение получилось.
Уберите из настроек fill: "forwards", остальное оставьте. Изменится одна строка из четырёх — какая и как?
В момент finished opacity снова 1: кончившаяся анимация без fill отпускает элемент к обычному стилю — та же ловушка, что с animation без forwards. Узел снимет только коммит после setOpen, и если браузер успеет нарисовать кадр между этими моментами, тост мигнёт полной яркостью перед тем, как пропасть.
Граница способа — там, где удаление начинаете не вы. Способ работает, пока тост закрывает ваш обработчик, который может подождать. Строку убрал filter после ответа сервера, пришли новые данные, сменилась страница — ждать некому: состояние уже новое, и ближайший коммит снимет узел.
Второй способ: уход — это состояние. Вместо флага open — фаза из трёх значений, объединение литералов "shown" | "leaving" | "gone". Клик ставит leaving: абзац остаётся в разметке и только получает класс, а у класса — @keyframes. Событие animationend браузер присылает, когда CSS-анимация доиграла; в JSX это onAnimationEnd, и оно ставит gone — только тогда условие убирает абзац. Эффект печатает фазу и число тостов после каждого коммита. Предскажите вторую строку.
Главная строка — «leaving — тостов 1»: тост уходит, но в DOM он есть, и анимации есть что играть. «Показан» и «есть в DOM» снова разошлись, только теперь по нашей воле: между ними лежит фаза, которую держит состояние, а не надежда на уборку. animationend пришло с именем fade-out, и фаза стала gone.
Строки о конце анимации приходят, только когда рамка примера видна: за краем экрана браузер не рисует её кадры, и анимация стоит. Нет строки animationend — прокрутите к рамке, и строки догонят.
У фазы есть ловушка. Добавим в тост значок ✓ с собственной анимацией появления на 100 мс — и больше ничего: даже клика в примере нет. Что напечатает пример?
Тост исчез сам, через 100 мс после появления. animationend всплывает, как click: значок доиграл pop, событие поднялось к абзацу, и обработчик абзаца принял чужой конец анимации за свой. Разбор из статьи про всплытие в React работает и здесь: target — где событие случилось, currentTarget — чей обработчик сейчас работает. Как отсеять чужие события — задача 2.
Граница второго способа: фазу держит родитель, и он должен знать, что элемент уходит. У списка это фаза у каждой строки — такой список ждёт в задаче 1, и в нём своя ловушка. А удаление по любой причине — строку убрал filter, пришли новые данные — требует сравнивать детей с прошлой отрисовкой: кто был и пропал, того вернуть в разметку до конца анимации. Писать это для каждого списка руками незачем — это и делает AnimatePresence.
Библиотека называется Motion, раньше — Framer Motion. Оба имени встретятся в чужом коде, а ставят сегодня пакет motion и импортируют из motion/react. В песочнице учебника Motion нет, поэтому дальше — листинги: файлы проекта с импортами вверху, проверенные в браузере, с записанным выводом. Записи сняты на motion 14.0.0 — подпись под каждой. Первый — тост из прошлого раздела.
Сравните с фазой: исчезли тип Phase, onAnimationEnd и ловушка с чужим animationend. Остались exit — как уходить, прямо на элементе, — и key. После «закрыть» абзац ещё около 200 мс в разметке, его opacity падает до нуля, потом узел снимается, и onExitComplete печатает строку один раз.
Внутри, в версии 14.0.0, AnimatePresence сравнивает ключи своих детей с прошлой отрисовкой. Пропавшего ребёнка он оставляет в разметке и сообщает ему, что тот больше не присутствует. motion-элемент запускает exit, а когда доиграют все уходящие, AnimatePresence снимает их разом и зовёт onExitComplete. Кто из детей остался в разметке, компонент хранит в своём состоянии, а отметку «этот ребёнок доиграл» проставляет в layout-эффекте — в исходнике 14.0.0 это прямо объяснено ценой конкурентных обновлений. Отсюда требование key: по ключу ребёнка узнают среди новых детей, как по карте из старых детей в сверке; что ещё значит ключ, покажет статья про ключ как команду «начни заново». Сами авторы в описании exit объясняют, зачем компонент нужен: React не даёт компоненту отложить своё снятие до конца анимации.
Теперь удаление по любой причине. «Убрать выполненные» — обычный filter в обработчике: без фазы у строк и без ожидания. Проп layout на строках двигает соседей; как он устроен, разберём после FLIP.
«Молоко» и «чай» гаснут 200 мс, и только когда их сняли, «сыр» едет на освободившееся место. Так ведёт себя режим по умолчанию, mode="sync": уходящие занимают место, пока не исчезнут. С <AnimatePresence mode="popLayout"> уходящих вынимают из потока сразу — в замере они с первого кадра стоят на прежних местах с position: absolute, и «сыр» едет одновременно с исчезновением. Третий режим, "wait", по описанию в типах держит на экране одного ребёнка за раз: новый входит, когда старый ушёл. onExitComplete в обоих запусках сработал один раз на всех ушедших.
Если уходить должна не анимация exit, а ваша собственная — например, element.animate из первого способа, — у AnimatePresence есть хук usePresence. Первый элемент его ответа, isPresent, — true, пока ребёнок на месте, и false, когда он пропал. Тогда вторым приходит функция safeToRemove, и AnimatePresence держит ребёнка в разметке, пока вы её не вызовете.
В таблице «Где CSS кончается» за исчезновением идёт перестройка раскладки: соседа удалили или переставили — остальные прыгают на новые места, а не едут к ним. Переходу здесь нечего делать: позицию в потоке меняет не свойство, а сама раскладка. Прыжок превращают в движение приёмом FLIP — по первым буквам четырёх шагов. First — где строка была. Last — где она теперь. Invert — сдвиг transform: translateY(first − last), который возвращает её на старое место. Play — анимация этого сдвига до нуля.
First — позиции прошлого коммита: их хранит Map в ref, потому что на разметку они не влияют. Last мерят после коммита, а Invert должен успеть раньше, чем браузер покажет новую раскладку. Поэтому всё происходит в useLayoutEffect, который выполняется до того, как браузер нарисует кадр; с useEffect браузер вправе сначала показать строки на новых местах. Высота строки задана — 30px, позиции считаются от верха списка. Предскажите три строки «было, стало, сдвиг» после «сыр наверх».
Сыр был на 60 и стал на 0: translateY(60px) ставит его туда, где он был, и анимация увозит сдвиг к нулю. Хлеб и молоко опустились на строку, у них сдвиг −30. Порядок строк в выводе — порядок в разметке после коммита, где сыр уже первый; first === last пропускает строки, которые не сдвинулись.
Миниатюра узнаёт строку по тексту: здесь он совпадает с ключом. В настоящем списке узлы строк собирают в Map по ключу ref-функцией с уборкой, как в задаче про прокрутку к выбранной строке из статьи про ref.
Где строка посреди настоящего движения, прогнозировать бесполезно — это зависит от кадров. У объекта Animation есть свой стоп-кадр: pause() и currentTime ставят анимацию на нужную долю пути, как отрицательная задержка в прологе. Пример останавливает каждую строку в начале и на середине пути (плавность linear) и мерит, где её видно. Предскажите три строки.
Сыр на экране в начале на 60, на середине на 30, а в разметке уже на 0: getBoundingClientRect учитывает transform. «Где строку видно» и «где она в раскладке» — разные числа, и в следующем примере это станет проблемой.
Где миниатюра кончается: нажмём «сыр вниз», пока строки ещё едут. Пример ставит все анимации первой перестановки на середину пути, печатает, где виден сыр, и кликает. Предскажите остальные строки — что второй коммит примет за «было» и «стало».
Во втором коммите «стало» у сыра — 90, хотя в раскладке он на 60: замер захватил transform остановленной анимации. А «было» — 0, чистая позиция прошлого коммита, хотя глаз видел сыр на 30. Сдвиг 0 − 90 уводит строку на −30, за верхний край списка, и только оттуда она едет вниз. В живом времени, без стоп-кадра, это скачок больше чем на 60 px за один кадр (Chrome 152 и 154). Правильное «было» — там, где строку видно прямо перед коммитом.
Хук здесь не виноват. В useLayoutEffect недоигранная анимация ещё висит на строке, и её сдвиг можно измерить — так миниатюру чинит задача 3. Но каждая такая поправка — ещё один случай, который держите в голове вы: меняется размер, внутри строки свои элементы, строка переезжает в другой список. Эти случаи и берёт на себя библиотека.
Тот же «сыр наверх» в Motion — один проп layout вместо Map, замеров и animate.
Порядок узлов в DOM меняется сразу, а на экране строки около 300 мс едут через transform — это тот же FLIP. Разница в месте замера. В версии 14.0.0 каждый motion-элемент с layout мерит себя в самом начале коммита, прямо перед тем как React правит DOM, и ещё раз после, поэтому хранить прошлые позиции самому не нужно. Прерывание из прошлого раздела в живом времени — «сыр вниз» через 150 мс после «сыр наверх», те же 300 мс и linear — Motion разворачивает с того места, где строку видно: в замере (13.2.0, Chrome 152) строка сдвигается за кадр не больше чем на 4 px, то есть просто едет, как у миниатюры, исправленной в задаче 3.
Сверх миниатюры, по описанию в типах: layout анимирует не только позицию, но и размер — через scale, и от этого искажаются дети, border-radius и box-shadow. Прямым детям ставят layout тоже, а скругление и тень библиотека поправит, если они заданы через её пропсы, например initial. layoutId связывает два разных элемента в одно движение: подчёркивание, которое переезжает с вкладки на вкладку, — это два элемента с одинаковым layoutId. Эти возможности здесь не запускались.
Любое движение на экране кому-то мешает. Настройка «уменьшить движение» и медиазапрос prefers-reduced-motion разобраны в прологе, а из JavaScript её читает matchMedia. В Motion 14.0.0 по умолчанию reducedMotion: "never": настройку человека библиотека не слушает, и полка из листинга выше при включённой настройке едет как обычно. Попросить её слушать можно обёрткой:
С reducedMotion="user" и включённой настройкой строки встают на места мгновенно, а onLayoutAnimationComplete всё равно приходит — три «доехал» в консоли есть. Без настройки полка едет, как раньше. Есть и хук useReducedMotion: он возвращает саму настройку — для случаев, когда движение нужно не выключить, а заменить, например сдвиг проявлением.
В Next.js App Router Toast, Tasks и Shelf из листингов этой статьи — клиентские компоненты, с "use client" первой строкой: у них состояние и обработчики, да и сама библиотека построена на хуках и эффектах — это строка «библиотека, внутри которой всё это есть» в таблице границы. Серверную страницу с motion-элементом из motion/react next 16.4.0 не отрисует («createMotionComponent is on the client»); для анимированного элемента без состояния в серверной странице у пакета есть вход motion/react-client.
У браузера есть и свой механизм: document.startViewTransition запоминает вид страницы до обновления и анимирует переход к новому. Это Baseline newly с 14.10.2025, а переходы между страницами через @view-transition пока limited — в Firefox их нет. React 19.3.0, вышедший 09.09.2026, добавил поверх него компонент ViewTransition; по описанию в типах он срабатывает только на несрочных обновлениях — например, тех, что помечает startTransition. В песочнице React 19.2.3, поэтому здесь это то, о чём стоит знать, а не на что опираться.
Motion — библиотека для react-dom: он указан в её peerDependencies, а в React Native движение устроено иначе. В части V его соберут заново — из значения, подписки и кадра, потом пружиной без длительности в Animated, и дойдут до жестов: касания, сдвига и свайпа.
- React снимает узел в коммите, одним шагом: уборка
useLayoutEffectещё видит его, но отложить снятие не может, и переход, запущенный в ней, гибнет вместе с узлом. Чтобы уход был виден, узел должен оставаться в описании, пока анимация не кончится. - Если удаление начинает ваш обработчик, его можно отложить:
element.animateчерез ref,await finished, потом сеттер.fill: "forwards"держит последний кадр до коммита. - Уход как состояние — фаза
"shown" | "leaving" | "gone"иonAnimationEnd.animationendвсплывает, и обработчик на родителе ловит концы чужих анимаций — свои события приходится отличать. AnimatePresenceсравнивает детей с прошлой отрисовкой поkeyи держит пропавшего в разметке, покаexitне доиграет, — поэтому удалять можно обычнымfilter.mode="popLayout"вынимает уходящих из потока сразу.- FLIP: First из прошлого коммита, Last после коммита, Invert —
translateY(first − last)вuseLayoutEffect, до кадра, Play — анимация доnone. При прерывании «было» — там, где строку видно.layoutв Motion мерит сам, в начале коммита, и умеет то, чего нет в миниатюре, — размер иlayoutId. - Motion по умолчанию не слушает
prefers-reduced-motion— нужен<MotionConfig reducedMotion="user">. В Next.js компоненты с состоянием и Motion — клиентские, с"use client".
1/4Компонент с тостом снимается со страницы. В уборке какого хука node.isConnected ещё true?
2/4node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 }) — без fill. Что вернёт getComputedStyle(node).opacity, когда выполнится finished?
3/4В <p onAnimationEnd={() => setPhase("gone")}> лежит значок со своей анимацией появления на 100 мс. Что случится без всякого клика?
4/4Строка была на 60px от верха списка, после коммита она на 0. С какого сдвига начинается анимация шага Play?
Убрать выполненные — и не оставить призрака
Список покупок с фазой у каждой строки: «убрать выполненные» ставит выполненным leaving: true, а onAnimationEnd строки убирает её из tasks. Анимации доигрывают, но после них в разметке остаётся «молоко» — невидимое, потому что forwards держит opacity: 0. Найдите причину и почините одной строкой. Последняя строка вывода придёт, когда рамка примера окажется на экране.
Обе строки доигрывают в одном кадре, и оба обработчика видят один и тот же снимок tasks, где строк ещё четыре. Первый filter убирает «молоко», второй — «чай», но из того же снимка, где «молоко» ещё есть, и его результат перекрывает первый. Когда между set и применением проходит время — ответ сервера, шаг анимации, — нужен обновитель:
Второй filter получает список уже без «молока», и в разметке остаются «хлеб, сыр». В листинге с AnimatePresence этой ловушки нет вовсе: удаление там — один filter в обработчике клика, а доигрывать уход — забота библиотеки.
Значок, который закрывает тост
Вернитесь к примеру со значком ✓ из раздела «Присутствие — отдельное состояние». Сделайте так, чтобы тост не пропадал сам: уход начинается только по «закрыть» и кончается анимацией самого тоста. Для проверки добавьте в конец примера клик через 300 мс, когда значок давно доиграл: setTimeout(() => document.getElementById("close")!.click(), 300);
Абзацу нужны только его собственные события. У всплывшего target — значок, а currentTarget — абзац, чей обработчик работает; у своего события они совпадают:
С кликом через 300 мс вывод — «коммит: shown — тостов 1», «коммит: leaving — тостов 1», «animationend: fade-out», «коммит: gone — тостов 0». Можно проверять и имя: if (e.animationName !== "fade-out") return;. Но такая проверка молча сломается, стоит переименовать @keyframes, а сравнение target с currentTarget не зависит ни от имён, ни от того, что лежит внутри тоста.
Перестановка, прерванная на полпути
Почините последний пример раздела про FLIP, где «сыр вниз» нажат посреди движения: вторая перестановка должна начинаться с того места, где строку видно. Подсказка: в useLayoutEffect недоигранная анимация ещё висит на строке — замер с ней и замер после cancel() дают её сдвиг. Печатайте «видно» вместо «было»: у сыра во втором коммите должно выйти «видно 30, стало 60».
У каждой строки два замера. Первый — как есть: новая раскладка плюс сдвиг недоигранной анимации. Потом анимации строки отменяются, и второй замер даёт чистое «стало». Разница между замерами — сдвиг, на котором строка стояла перед коммитом; вместе с «было» прошлого коммита он даёт место, где строку видел глаз.
Второй коммит печатает «хлеб: видно 15, стало 0», «молоко: видно 45, стало 30», «сыр: видно 30, стало 60» — сыр едет вниз с 30, где его и видно. Кадра между cancel() и новой анимацией браузер не нарисует: всё происходит в одном layout-эффекте. В живом времени, без стоп-кадра, сыр у такой миниатюры сдвигается за кадр не больше чем на 4 px — вместо скачка больше чем на 60.