Я дизайнер в сервисе бронирования отелей Моя Бронь. Расскажу про 3 анимации, которые мне нравятся и над которыми я долго работал.
Все три паттерна я подсмотрел в других продуктах: Airbnb, Wallet в Телеграме, Family. В каждом блоке расскажу, откуда взял и что поменял под наш контекст. Разбираю не только макеты, но и реализацию: конкретные значения cubic-bezier, длительности с обоснованием, плюс грабли с FLIP, Teleport и :key + .
1. Поисковая строка в шапке
На странице выдачи номеров удобно иметь поисковую строку с датами и городом под рукой:
Полистал, понял что по ценам не подходит и решил выбрать другие даты.
Открыто несколько вкладок на разные даты, когда выбираешь лучшее предложение и нужно не запутаться где, что, какие даты.
Многие платформы закрепляют вверху большое поисковое поле ~100px под основным хедером

На десктопе место по вертикали особенно дорого. Ширины у нас много, а по высоте место на мониторе ноутбука съедают адресная строка хрома и док бар. Итого остаётся ~600–700px полезного пространства. Из них ещё часть отнимает эта огромная шапка.
Мы поместили строку с поиском сразу в основную шапку, где логотип и всё остальное. Сделали её компактной, а при нажатии она плавно разворачивается в полноценную строку, сразу с открытым полем, на которое нажал.

Этот паттерн подсмотрел у Airbnb, у них поиск тоже в шапке. Когда я был там пользователем, у меня было ощущение «о, как удобно». Хотелось, чтобы и у нас было так.
В итоге мы выигрываем место для отелей. Пользователи пришли именно за ними, и они должны быть центральными на экране.
Как устроено
Технически это классический FLIP поверх «призрачного» слоя через Teleport. Сам компонент в шапке мы не анимируем. Во время анимации в <body> живёт его клон с position: fixed, который и едет из точки А в точку Б.
В DOM одновременно живут три экземпляра поисковой формы:
Реальный свёрнутый. Он в шапке всегда, виден, ловит клик. Высота 36px.
Реальный развёрнутый.
visibility: hidden,pointer-events: none. Нужен только как измерительный слой, с него снимается финальный bounding rect.Призрак в
<body>.position: fixed, высокий z-index. Это то, что пользователь видит во время анимации и то, на чём он потом работает.
Открытие: снимаем from со свёрнутого, монтируем призрак развёрнутого вида на место свёрнутого, переключаем layout шапки, снимаем to через два nextTick, инверсия через transform, запускаем transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1). Параллельно opacity полей внутри едет с 0 в 1 за 180ms. Закрытие зеркально.
Одна неочевидная мелочь. При клике на «Город» нужно сразу сфокусировать input, иначе пользователь видит лишнее мигание. Но ref на input подъезжает асинхронно после монтажа призрака, так что мы крутим requestAnimationFrame-цикл до 20 кадров, пока input не появится в DOM.
Грабли
border-radius: inherit на компоненте внутри призрака требует, чтобы родительский контейнер призрака тоже наследовал радиус. Иначе при pending-анимации скругления спрямляются в плоские углы.
Pending-бордер реализован через псевдоэлемент ::after. На GPU-scale он мерцает. Пришлось при закрытии переносить анимацию рамки с псевдоэлемента на сам контейнер призрака, а внутренний ::after гасить.
На планшете нужно жёстко фиксировать ширины колонок в grid-template-columns. Без этого при FLIP-scale поля прыгают во время анимации, потому что 1fr пересчитывается.
prefers-reduced-motion. Обе ветки open/close при mql.matches пропускают FLIP полностью и просто переключают состояния. Без анимации для тех, кто её отключил в настройках ОС.
На мобилке FLIP не делаем вообще. Места там нет. Компактная строка превращается в pill-баттон, который открывает обычную полноэкранную модалку с поиском внутри.
Посмотреть вживую можно на примере страницы поиска отелей.
2. Анимации статусов заказа
После оплаты пользователь попадает на страницу заказа. В этот момент мы отправляем запрос на создание брони нашему вендору, и дальше возможны три исхода:
Бронь подтвердят быстро, 5–15 секунд
Отель отклонит бронь: овербукинг или иные причины
Уйдёт на ручное подтверждение отелем, может занять часы
Пользователь в это время сидит на странице и смотрит. Хочется создать ощущение плавности и непрерывности взаимодействия, что процесс продолжается. Если не анимировать и просто написать «Ждем подтверждение отеля...», это выглядит как зависание, и рука тянется рефрешить страницу. Поэтому мы делаем плавные переходы. Спиннер мягко превращается в галочку или крест, тексты статусов и высота карточки тоже меняется плавно.
Вдохновлялся криптокошельком Family. У них здорово показаны этапы отправки перевода в блокчейне: принят, в ожидании, подтверждён. И параллельно на ByBit, например, после отправки перевода приходится обновлять страницу, чтобы увидеть выход из pending. Вот такого ощущения «зависло, надо обновлять» хотелось избежать.
Что анимируем
Четыре отдельных движения, которые работают одновременно.
Иконка статуса. Lottie-анимация. Loading в цикле, при смене статуса проигрывается «выход»: loading-to-check или loading-to-cross. Важная мелочь: новую иконку ставим после окончания loading-анимации, не во время. Иначе виден шов между лупом и переходом.
Заголовок. Когда иконка меняется с одной строки на две, заголовок плавно подъезжает по вертикали, чтобы остаться на одной линии с её центром. 0.3s, cubic-bezier(0.32, 0.94, 0.60, 1).
Сам текст статуса. 3D-перелистывание: старый текст уходит вниз с rotateX 90°, новый прилетает сверху с тем же rotateX. Как будто пластинка с текстом физически переворачивается. Приём подсмотрел в TG Wallet, там так же крутятся строки на экране загрузки поиска P2P обмена. 3D вместо обычного fade — интереснее выглядит, плюс лейаут не дёргается: текст крутится в одной строке, не наезжает на элементы другие. Длительность 0.3s, easing cubic-bezier(0.76, 0, 0.24, 1).
Высота карточки. Контент внутри меняется. Где-то CountdownTimer, где-то список «оплачено / ждём подтверждения / подтверждено», где-то ничего. Высота едет за контентом плавно, лишнее обрезается через overflow: hidden. 0.3s, cubic-bezier(0.32, 0.94, 0.60, 1).
Как устроено (на примере Telegram miniapp)
Никаких стейт-машин и оркестраторов, просто Vue-реактивность. Здесь три-четыре состояния и линейный переход между ними, XState или подобное было бы избыточно. У нас страница поллит бэк каждые 5 секунд, свежие данные пишутся в рефы, computed собирает текущий статус.
Вся анимация смены в одной строчке:
<TransitionHeight> <StatusInfoView :key="keyStatus" v-if="statusInfo.list" :list="statusInfo.list" /> </TransitionHeight>
keyStatus — это конкатенация ключевых полей статуса. Любое поле изменилось, :key поменялся, Vue считает компонент новым. Старый делает leave (fade + collapse), новый enter. Никакой ручной координации, никаких таймеров.
<TransitionHeight> внутри — это обёртка над <Transition>, которая замеряет реальную высоту нового слота и анимирует height от 0 до неё.
Полезное
window.changeStatus и window.changeMethodPay в onMounted для отладки. Из консоли вручную переставить статус, и сразу видно анимацию. Не нужно ждать нужное состояние от бэка. На настройке тестовых сценариев сэкономили несколько часов.
Посмотреть вживую можно забронировав отель в приложении или тг-миниапе Моя Бронь (оплачивать бронь не обязательно).
3. Анимация загрузки на странице поиска
Мы платформа по поиску и бронированию отелей, и поиск должен работать хорошо. Ценность сервиса в том, что тут удобно искать. А удобно — это быстро. Если страница грузится минуту, никакая анимация не спасёт. Хочется закрыть и забыть.
В прошлой статье я рассказывал, как у нас устроен кэш поиска. Кэшируем на 2 недели вперёд, и от «нажал Найти» до «увидел результаты» проходит меньше 7 секунд. Но есть запросы на дальние даты, которые мы не кэшируем. Там идём прямо к вендору, и поиск занимает 15–20 секунд.
Вот эти 15–20 секунд — наша задача. Хочется развлечь пользователя на это время: чтобы что-то крутилось, менялось, и не выглядело, что приложение зависло. Поэтому у нас сверху прогрессбар, а по центру лупа и меняющиеся тексты. Много движения, чтобы экран выглядел рабочим.
Что анимируем
Меняющиеся тексты. Массив из i18n: «Подбираем лучшие отели», «Проверяем рейтинг отелей», «Ищем свободные места». Меняются каждые 3 секунды.
Сама смена текста — тот же 3D-барабан, что и при смене статусов заказа в прошлом блоке.
Технически это тот же :key + <Transition>, что и в блоке статусов. Специфичная мелочь: на enter-active и leave-active нужно ставить position: absolute. Иначе на 0.6 секунды лейаут прыгает: старый текст ещё уезжает, а новый уже занял место. Контейнер при этом делаем position: relative с фиксированной высотой строки.
Прогрессбар сверху. Тонкая линия 2px, ползёт слева направо. Поверх бегает белый блик.
Прогрессбар калиброванный, а не реальный
Настоящий прогрессбар тут невозможен в принципе. Бэкенд опрашивает вендора, а вендор не отдаёт ни количества пройденных шагов, ни ETA. Мы не знаем заранее, найдёт ли он 50 отелей или 5000, и не знаем, ответит за 5 секунд или за 25. Любая «честная» индикация либо стояла бы мёртвой строкой первые секунды, либо прыгала бы рывками от 0% к 100% в момент прихода ответа. Поэтому мы не индицируем прогресс — мы его имитируем.
Сама имитация максимально простая. setInterval каждые 150 мс наращивает значение на 1%:
const timer = setInterval(() => { progress.value++; }, 150);
Шкала подобрана под типичное время поиска: 150мс × 100 = 15 секунд, медианный ответ приходит за 15–20. В среднем бар добегает до 100% близко к моменту ответа, в хвостовых случаях стоит до 5 секунд на сотке. Это компромисс с альтернативой — асимптотическим замедлением около 90%. Мы выбрали честный паркинг на 100% вместо непредсказуемого jitter.
Пользователь видит движение и не воспринимает ожидание как «зависло». Реальные ориентиры здесь — Lottie с лупой и ротация текстов. Они работают независимо от того, сколько идёт ответ. Прогрессбар — это третий элемент движения, который усиливает ощущение «оно работает».
Когда ответ приходит, clearInterval, прогресс долетает до 100, через 0.75 секунды паузы (видно, как полоса достигла конца) полоса плавно гаснет. Пользователь видит логичное завершение и сразу список отелей.
Концепт: цитаты русских писателей вместо служебных текстов
В играх на долгих экранах загрузки часто показывают советы или цитаты, и это работает. У нас философия проекта — российский, с душой, сделанный людьми. Хочется и загрузку с душой, вместо «проверяем рейтинг», цитаты русских писателей про отели и гостиницы. Чтение в момент ожидания приближает к высокому. Пока не внедрили, лежит в папке «когда дойдут руки».
