Як плитки гри «виростають» в ігрове поле завдяки одному CSS-правилу: код, таймінги і баг у два пікселі, який зламав усе на телефонах.
Коротко
- Сторінки анімуються при переходах між собою завдяки одному CSS-правилу.
- Торкаєтесь плитки гри - і вона виростає в ігрове поле. Виходите - стискається назад.
- 12 жовтня переповнення у 2 px змусило телефони пропускати морфінг; виправив це один рядок CSS. Код і таймінги - нижче.
Що я зробив
Торкніться синьої плитки Sprint у Ball Quiz, і вона виросте в ігрове поле. Натисніть Leave - і вона стиснеться назад.
Ball Quiz - безкоштовна футбольна вікторина про емблеми клубів, яку я пишу на Laravel і Livewire, а морфінг у ній - звичайний CSS. А ще він перестав працювати на телефонах через переповнення у два пікселі - до цього я ще дійду. Спершу найцікавіше. Мій власний сайт використовує ту саму можливість браузера - view transitions, тож я міг порівняти обидва проєкти.
Рух показує, що змінилося
Обидва проєкти тримаються одного правила: речі рухаються, щоб показати, що змінилося.
Ball Quiz: аркада
- Клавіші при натисканні падають на твердий уступ
- Правильна відповідь «вистрибує»
- На фінальній картці бали накручуються
Мій сайт: спокійно
- Сторінки плавно змінюють одна одну при переході
- Ніщо не підстрибує
- Числа ніколи не накручуються: бізнес-показник не має виглядати як ігровий автомат
Одне правило перетворює завантаження сторінок на переходи
Сторінки Ball Quiz лишаються звичайними документами, а браузер анімує завантаження між ними.
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) {
@view-transition { navigation: none; }
}
За замовчуванням браузер робить знімок старої сторінки, завантажує нову і плавно переходить між ними (crossfade). Браузери без підтримки просто завантажують сторінку, тож нічого не втрачається.
Я налаштував усе так, щоб вихід був швидким (140 мс), а поява - трохи повільнішою (260 мс), з підйомом на 8 px, який читається як «нова сторінка», але не перетворює сайт на слайдшоу:
::view-transition-old(root) { animation: 140ms cubic-bezier(.4, 0, 1, 1) both bq-vt-page-out; }
::view-transition-new(root) { animation: 260ms cubic-bezier(.16, 1, .3, 1) both bq-vt-page-in; }
@keyframes bq-vt-page-out { to { opacity: 0; } }
@keyframes bq-vt-page-in { from { opacity: 0; transform: translateY(8px); } }
Дайте рухомому елементу ім'я
Поставте однаковий view-transition-name на елемент на кожній зі сторінок, і браузер плавно перетворить положення і розмір одного на інший.
Ім'я має бути унікальним на сторінці в момент знімка, а в лобі багато плиток, тож кожна гра отримує власне. Ігрових режимів дев'ять, плюс щоденна гра й альбом наліпок - разом одинадцять імен. Ось Sprint:
.bq-play-tile[data-game="sprint"],
.bq-game-page[data-game="sprint"][data-game-active="true"] .desk-quiz {
view-transition-name: bq-game-sprint;
}
::view-transition-group(bq-game-sprint) { animation-duration: 380ms; }
Перевірка data-game-active важлива: ім'я отримує лише поле, на якому справді йде раунд, тож екран налаштувань не може «вкрасти» морфінг.
Плитка і поле мають різну форму, тому я обрізаю знімки, а не даю їм сплющуватись:
::view-transition-old(*), ::view-transition-new(*) {
height: 100%; object-fit: cover; overflow: clip;
}
Вирішіть, що лишається на місці
Це була моя улюблена частина: рух виглядає добре лише тоді, коли щось стоїть нерухомо.
Шапка отримує власне ім'я та анімацію нульової тривалості, тож вона лишається на місці, поки сторінка під нею змінюється. Активна «пігулка» навігації теж отримує ім'я і за 300 мс переїжджає до наступного посилання.
.desk-header { view-transition-name: bq-header; }
::view-transition-group(bq-header) { animation-duration: 0s; }
Нижня панель з відповіддю отримує те саме. Без власного імені вона стиснулася б у плитку, до якої ніколи не належала, тож вона йде окремою доріжкою - з'їжджає вниз за 160 мс:
.bq-game-feedback { view-transition-name: bq-sheet; }
::view-transition-old(bq-sheet) { animation: 160ms cubic-bezier(.4, 0, 1, 1) both bq-vt-sheet-out; }
@keyframes bq-vt-sheet-out { to { transform: translateY(100%); } }
Коли наступна сторінка повільна
Браузер тримає стару сторінку на екрані, доки нова не буде готова, а це незручно, коли на це йде дві секунди.
Тому зверху є невеликий скрипт. Через 120 мс він показує смугу прогресу вгорі. Через 350 мс вставляє скелетон у формі сторінки призначення, і плитка одразу перетворюється на цей скелетон. Коли приходить справжня сторінка, перехід між документами (cross-document transition) перетворює скелетон на неї. У спрощеному вигляді заміна виглядає так:
const swap = () => {
morphFrom.style.viewTransitionName = 'none';
skeleton.style.viewTransitionName = `bq-game-${morphFrom.dataset.game}`;
main.prepend(skeleton);
};
document.startViewTransition(swap).ready.catch(() => {});
Плитка віддає своє ім'я, скелетон його забирає, тож ім'я лишається унікальним. catch тут навмисно: браузер пропускає перехід, якщо наступна сторінка прийшла раніше, і це нормально.
Баг у два пікселі
12 жовтня морфінг працював на десктопі й мовчки не спрацьовував на телефонах. Жодної помилки, просто різка зміна кадру.
View transition пропускається (браузер кидає InvalidStateError), якщо область, з якої робиться знімок, змінюється між двома сторінками. На мобільних будь-яке горизонтальне переповнення розширює layout viewport. Поле в'їжджає з translateX(20px), і в момент появи це переповнювало сторінку на 2 px. Двох пікселів вистачило, щоб браузер відкинув увесь перехід.
Виправлення - один рядок:
main { overflow-x: clip; }
Я навмисно взяв clip, а не hidden. hidden перетворює main на контейнер прокручування, а це зламало б липку ігрову панель. clip обрізає переповнення і більше нічого не чіпає. Я додав на це фронтенд-тест, бо саме такі баги тихо повертаються.
Дрібний відгук усередині раунду
Відповіді змінюють стан на місці: «вистрибування» для правильної, коротке трусіння для неправильної - ще до того, як ви прочитаєте «Incorrect».
- 280 мс - питання в'їжджає на 20px;
- 260 мс - панель з відповіддю виїжджає вгору;
- 160 мс - панель з'їжджає при виході;
- 380 мс - морфінг плитки в поле.
Обидві анімації появи працюють лише тоді, коли відвідувач не просив зменшити рух.
@media (prefers-reduced-motion: no-preference) {
.desk-quiz-play { animation: bq-question-in 280ms cubic-bezier(.16, 1, .3, 1) backwards; }
.bq-game-feedback { animation: bq-sheet-up 260ms cubic-bezier(.16, 1, .3, 1) backwards; }
}
@keyframes bq-question-in { from { opacity: 0; transform: translateX(20px); } }
@keyframes bq-sheet-up { from { transform: translateY(100%); } }
На моєму сайті
Те саме правило @view-transition робить кожну зміну сторінки на моєму сайті коротким плавним переходом.
Перемикання між світлою і темною темою використовує перехід у межах документа (same-document transition), тож уся сторінка плавно переходить до нової теми замість спалаху. Якщо хочеться чогось гучнішого, ось варіант, який я зробив як демо: новий стиль розходиться колом від натиснутої кнопки. Це перехід у межах документа плюс анімація clip-path:
const r = button.getBoundingClientRect();
root.style.setProperty('--vx', `${r.left + r.width / 2}px`);
root.style.setProperty('--vy', `${r.top + r.height / 2}px`);
root.classList.add('vt-theme');
document.startViewTransition(() => applyStyle(button.dataset.style));
html.vt-theme::view-transition-old(root) { animation: none; }
html.vt-theme::view-transition-new(root) { animation: wipe .7s cubic-bezier(.65, 0, .35, 1) both; }
@keyframes wipe {
from { clip-path: circle(0 at var(--vx) var(--vy)); }
to { clip-path: circle(150vmax at var(--vx) var(--vy)); }
}
Заголовки і смуга прогресу
Заголовку, що перетікає між сторінками, потрібне ще одне правило, інакше він розтягується посеред руху.
Знімки масштабуються як зображення. object-fit: none; object-position: left top це зупиняє і прив'язує заголовок до лівого верхнього кута.
Смуга прогресу читання використовує animation-timeline: scroll(root) без жодного слухача прокручування, плюс невеликий резервний варіант на JavaScript.
Зменшений рух
Обидва проєкти зважають на prefers-reduced-motion.
Ball Quiz вимикає view transitions і прибирає трансформації, конфеті, «вистрибування» балів і смугу автопереходу, але лишає текст про те, що наступне питання вже на підході. Мій сайт лишає вміст і прибирає рух.
Що зламалось і що працює
Станом на 2 листопада 2026 року ball-quiz.com віддає і переходи, і виправлення для телефонів.
- Працює. Таблиця стилів має обидва правила
@view-transition, імена на кшталт bq-game-sprint, bq-header і bq-sheet, а також overflow-x: clip на main.
- Ламалось. Попередній коміт зламав 13 фронтенд-тестів; коміт від 10 жовтня їх полагодив. Потім переповнення у 2 px змусило телефони пропускати морфінг - виправлено 12 жовтня.
- Далі. Перевірити на справжніх телефонах, а потім порівняти кількість раундів у лобі до і після плиток в один дотик.
Обмеження: я прочитав таблицю стилів і скрипт і перевірив правила на живому сайті, але не перевіряв рух у кожному браузері. Я не вимірював, скільки раундів зіграно чи сторінок прочитано. Мені здається, що так краще; чи справді краще - окреме питання, і я волію це з'ясувати, а не припускати.
Що я радив би спробувати
Почніть з правила, яке вмикає переходи: найкраще співвідношення витрат і ефекту, яке я знайшов.
- Додайте
@view-transition { navigation: auto; } і вимкніть його для зменшеного руху.
- Дайте спільному елементу однаковий унікальний
view-transition-name на обох сторінках. У списку давайте ім'я кожному елементу.
- Вирішіть, що лишається нерухомим. Закріплена шапка робить решту руху свідомим.
- Робіть вихід швидшим за появу.
- Обрізайте знімки через
object-fit, коли форми або текст розтягуються, і перевірте на телефоні, чи не з'явився зайвий піксель переповнення.
Примітка редакції: а як щодо wire:navigate?
Автор не пояснює, як це поєднується з wire:navigate, а це важливо. Сторінки Ball Quiz лишаються звичайними документами, тобто кожен перехід - це повноцінне завантаження сторінки. Саме на такі переходи реагує @view-transition { navigation: auto; }.
wire:navigate працює інакше: Livewire перехоплює клік, завантажує нову сторінку у фоні та підміняє URL, <title> і вміст <body> без перезавантаження. Браузер не бачить переходу між документами, тож правило @view-transition тут не спрацює, і за замовчуванням нова сторінка з'являється миттєво.
Для цього випадку в Livewire 4 є власна директива wire:transition.navigate (див. документацію Livewire). Позначений нею елемент анімується через той самий View Transitions API під час переходу через wire:navigate; можна позначити <main>, <body> або весь <html>. Поруч з основною областю можна задати іменовані, і однаково названі елементи на двох сторінках перетікають один в одного - як плитки в цій статті. Правило те саме: ім'я має бути унікальним на сторінці, тож у циклі додавайте до нього ідентифікатор. Браузери без підтримки, користувачі зі зменшеним рухом і сторінки з відкритим діалогом отримують миттєву заміну.