У попередній статті автор уже показував, як обчислити кількість колонок, рядків і координати елементів у звичайній адаптивній сітці. Оскільки в такій сітці кожен елемент займає рівно один рядок і одну колонку та розставляється автоматично, задача розв'язується простою математикою.

.container { --s: 120px; --g: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--s),1fr)); gap: var(--g); container-type: inline-size; } .container > * { --n: round(down, (100cqw + var(--g))/(var(--s) + var(--g))); --m: round(up, sibling-count()/var(--n)); --x: round(down,(sibling-index() - 1)/var(--n)); --y: mod(sibling-index() - 1, var(--n)); }

Оскільки всі отримані значення цілі, їх можна вивести просто через counter.

А що робити, якщо елементи розкидані по сітці випадково — кожен може стояти будь-де і охоплювати кілька колонок і рядків одразу? Чи вдасться дістати ті самі дані в такій ситуації?

У такому разі про фіксовані координати вже не йдеться, бо сітка нерівномірна. Натомість потрібно визначати саме позицію кожного окремого елемента: з якої лінії він починається і на якій закінчується — і по колонках, і по рядках.

Автор наводить приклад, де рожевим кольором позначені лінії сітки (їх показують інструменти розробника), а всередині кожного елемента виведено його реальну позицію за колонкою і рядком. Наприклад, третій елемент охоплює дві колонки — з третьої по п'яту лінію, і два рядки — з першої по третю.

Чи можливо реалізувати це виключно засобами CSS? Так, можливо — хоч і не так просто, як у базовому варіанті з чотирма рядками коду. На момент написання статті повну підтримку потрібних можливостей мають лише Chrome і Edge.

У демонстрації можна додавати й видаляти елементи, змінювати їхнє розташування та розмір екрана чи сітки — усі значення перерахуються автоматично, і все це без жодного рядка JavaScript.

Пояснення першого прикладу коду автор уже давав у попередній статті, а схожий підхід описувала Ana Tudor в іншому матеріалі. Тим, хто впевнений у своїх знаннях CSS, можна пропустити ці джерела, але новачкам і тим, хто не стежив за новими можливостями мови, варто спершу заглянути туди — там пояснено роботу з container, функціями sibling-*() та іншими математичними інструментами.

Налаштування сітки

Базова адаптивна сітка описується кількома змінними, що контролюють розміри.

.container { --c: 150px; --r: 110px; --g: 8px; display: grid; grid-template-columns: repeat(auto-fill,minmax(var(--c),1fr)); grid-auto-rows: var(--r); gap: var(--g); }

Головна вимога — однакова ширина колонок і однакова висота рядків. З колонками все просто, це стандартна конфігурація, але висота рядків може стати обмеженням: техніка працює лише тоді, коли всі рядки мають наперед відому фіксовану висоту.

Утім, дані про рядки й колонки між собою не пов'язані — вимога рівної висоти стосується тільки визначення позиції за рядком. Позицію за колонкою можна отримати й без неї, тож підхід залишається корисним навіть у сітках зі змінною або невідомою висотою рядків.

Ідея підходу

Спершу варто позначити змінними всі значення, які потрібно отримати.

.container > * { --n: ... ; --m: ... ; --col-s: ... ; --col-e: ... ; --row-s: ... ; --row-e: ... ; }

Крім кількості колонок, усі інші значення визначатимуться за допомогою Scroll-Driven Animations. Якщо ця технологія незнайома, варто спершу переглянути гайд на MDN — далі все буде пояснено, але базове уявлення про механізм знадобиться, інакше легко загубитися.

Кожен елемент може опинитися будь-де в сітці, тож ідея полягає у відстеженні позиції кожної його сторони окремо — а оскільки сторін чотири, отримуємо чотири значення позиції.

Але як стежити за позицією лише однієї сторони, якщо зазвичай відстежується позиція всього елемента в контейнері? У специфікації сказано таке:

There are two types of scroll-driven timelines: Scroll Progress Timelines, which are linked to the scroll progress of a particular scroll container View Progress Timelines, which are linked to the view progress of a particular box through a scrollport

специфікація Scroll-Driven Animations

Автора цікавить саме другий тип — прогрес перегляду конкретного блоку через вьюпорт скролу, тобто позиція елемента всередині скрол-контейнера дозволяє керувати конкретною анімацією.

Заглибившись у специфікацію далі, можна знайти конфігурацію, де саме позиція «початкового краю» або «кінцевого краю» елемента визначає прогрес анімації.

Прогрес, таймлайн, анімація, стартовий край — усе це звучить заплутано, і уявити, як Scroll-Driven Animations (де нічого не скролиться і нічого не анімується у звичному сенсі) можуть обчислювати позицію кожної сторони, справді непросто. Автор радить не намагатися одразу все зрозуміти абстрактно, а перейти до реалізації — код прояснить картину набагато краще.

Реалізація

Спочатку — кількість колонок і рядків. У базовій конфігурації сітки використовувався такий код.

.container { --s: 120px; --g: 10px; container-type: inline-size; } .container > * { --n: round(down,(100cqw + var(--g))/(var(--s) + var(--g))); --m: round(up,sibling-count()/var(--n)); }

Кількість колонок обчислюється через ширину контейнера (100cqw), а кількість рядків — через загальну кількість елементів і кількість колонок (детальний розрахунок наведено в попередній статті).

Формула для колонок лишається чинною й для нової сітки, а от формула для рядків уже не працює: коли елементи можуть охоплювати кілька рядків і колонок одразу, покладатися на sibling-count() для визначення кількості рядків більше не можна.

Причина проста: візьмемо сітку з 5 колонками й одним елементом, що охоплює всі колонки та 3 рядки. За старою формулою --m дорівнюватиме 1, хоча насправді рядків три.

Замість цього варто враховувати висоту контейнера й застосувати ту саму логіку, що й для колонок.

.container { --c: 150px; --r: 110px; --g: 8px; container-type: inline-size; --h: ???; } .container > * { --n: round(down,(100cqw + var(--g))/(var(--s) + var(--g))); --m: round((var(--h) + var(--g))/(var(--r) + var(--g))); }

З'являється нова змінна для висоти контейнера, обчислення якої — окрема хитра задача, розглянута далі. Наразі ж маємо ширину контейнера (100cqw) і висоту (--h) для обчислення кількості колонок і рядків.

Далі — найцікавіше: визначення позиції елемента. Почнемо з простої демонстрації базового принципу.

.container { overflow: hidden; } .container > * { view-timeline: --x x; timeline-scope: --x; animation: --x linear both; animation-timeline: --x; } @keyframes --x { 0% {background: green} to {background: red } }

Тут визначається власний таймлайн через view-timeline, що стежить за позицією елемента по осі x, а далі animation-timeline прив'язує до нього анімацію, яка змінює колір фону. Колір елемента переходить від зеленого до червоного залежно від його позиції.

Варто зважити на дві речі. По-перше, overflow: hidden на контейнері сітки є обов'язковим — саме це визначає скрол-контейнер, і позиція елемента відраховується від найближчого такого контейнера, яким має бути сама сітка.

По-друге, потрібен timeline-scope: кожен елемент отримує власний таймлайн, і всі вони матимуть однакове ім'я, тож слід обмежити доступ кожного елемента лише до свого таймлайна. Робота зі Scroll-Driven Animations завжди пов'язана з питаннями скоупінгу й overflow, і про них не варто забувати.

У попередній демонстрації помітно, що перший елемент не стає повністю «червоним», а другий — не повністю «зеленим»: жоден із них не досягає крайніх точок анімації. Це можна виправити, підкоригувавши animation-range з урахуванням сторін елемента.

Почнемо з лівої сторони. У специфікації для «початкового краю» елемента (лівої сторони) є два значення: entry-crossing 0% і exit-crossing 0%.

Значення exit-crossing 0% підходить одразу — воно дає позицію, коли лівий край елемента стоїть на першій лінії сітки (елемент починається з першої колонки). А entry-crossing 0% вказує на останню лінію сітки, що виводить елемент за межі; тут потрібне коригування до передостанньої лінії.

Для цього використовується entry-crossing calc(0% + var(--c)) або просто entry-crossing var(--c), де --c — розмір однієї колонки.

animation-range: entry-crossing var(--c) exit-crossing 0%;

Тепер, замість зміни кольору, можна анімувати змінну від 1 до 0.

@keyframes --x { 0% {--x: 1} to {--x: 0} }

Якщо лівий край елемента стоїть на першій лінії сітки, отримуємо 0; якщо на передостанній — 1. Оскільки колонки мають однакову ширину, крок анімації дорівнює 1/(N - 1), де N — кількість колонок.

У демонстрації з 4 колонками анімація може давати такі значення залежно від позиції елемента: 0, 0,33, 0,66, 1. Помноживши їх на 3 (N - 1), отримуємо 0, 1, 2, 3. Додавши 1, дістаємо перше потрібне значення — позицію початку колонки.

--col-s: round(var(--x)*(var(--n) - 1) + 1);

Функція round() тут гарантує, що результат завжди буде цілим числом.

Оновимо демонстрацію й виведемо значення всередині кожного елемента.

.container > *:before { content: counter(x); counter-reset: x var(--col-s); }

Можна додавати скільки завгодно елементів і робити їх завширшки скільки завгодно колонок — значення завжди правильно відповідатиме позиції початку колонки. Ліва сторона елемента керує анімацією, що анімує змінну, а та змінна використовується для обчислення позиції.

Аналогічно можна опрацювати праву сторону (кінець колонки). Оскільки й це відбувається по осі x, новий таймлайн не потрібен — різниця лише в animation-range.

animation-range: entry-crossing 100% exit-crossing 100%;

Це схоже на попередній варіант, тільки замість 0% застосовується 100%. Одна з позицій виявляється поза межами, тож її потрібно скоригувати зсувом на розмір колонки.

animation-range: entry-crossing 100% exit-crossing calc(100% - var(--c))

Далі — та ж логіка: нова анімація, нова змінна і формула для кінця колонки.

.container > * { view-timeline: --x x; timeline-scope: --x; animation: --x-s linear both, --x-e linear both; animation-timeline: --x; animation-range: entry-crossing var(--c) exit-crossing 0%, entry-crossing 100% exit-crossing calc(100% - var(--c)); --col-s: round(var(--x-s)*(var(--n) - 1) + 1); --col-e: round(var(--x-e)*(var(--n) - 1) + 2); } @keyframes --x-s { 0% {--x-s: 1} to {--x-s: 0} } @keyframes --x-e { 0% {--x-e: 1} to {--x-e: 0} }

Тепер маємо два значення, які можна вивести всередині елемента. Додаткову двійку в формулі кінця колонки пояснює логіка ліній сітки: у конфігурації з 4 колонками лінії сітки йдуть від 1 до 5, початок колонки може бути в діапазоні від 1 до 4, а кінець — від 2 до 5. Елемент, що починається на лінії 5 або закінчується на лінії 1, виходив би за межі сітки, тому ці значення виключено.

В обох випадках анімація дає значення в діапазоні [0, 1], яке множимо на 3, отримуючи діапазон [0, 3]. Для початку колонки додаємо 1, щоб отримати діапазон [1, 4], а для кінця колонки логічно потрібна двійка, щоб отримати діапазон [2, 5].

З позицією рядка все відбувається так само, тільки по осі y замість осі x, зі змінною --r (висота рядка) замість --c (ширина колонки) і --m (кількість рядків) замість --n (кількість колонок).

Об'єднавши все разом, отримуємо повний код техніки.

.container > * { --n: round(down,(100cqw + var(--g))/(var(--c) + var(--g))); --m: round((var(--h) + var(--g))/(var(--r) + var(--g))); --col-s: round(var(--x-s)*(var(--n) - 1) + 1); --col-e: round(var(--x-e)*(var(--n) - 1) + 2); --row-s: round(var(--y-s)*(var(--m) - 1) + 1); --row-e: round(var(--y-e)*(var(--m) - 1) + 2); timeline-scope --x,--y; view-timeline: --x x,--y y; animation: linear both; animation-name: --x-s,--x-e,--y-s,--y-e; animation-timeline: --x,--x,--y,--y; animation-range: entry-crossing var(--c) exit-crossing 0%, entry-crossing 100% exit-crossing calc(100% - var(--c)), entry-crossing var(--r) exit-crossing 0%, entry-crossing 100% exit-crossing calc(100% - var(--r)); } @keyframes --x-s {0% {--x-s: 1} to {--x-s: 0}} @keyframes --x-e {0% {--x-e: 1} to {--x-e: 0}} @keyframes --y-s {0% {--y-s: 1} to {--y-s: 0}} @keyframes --y-e {0% {--y-e: 1} to {--y-e: 0}}

Разом чотири анімації, кожна анімує свою змінну від 1 до 0: дві прив'язані до осі x, дві — до осі y. Далі йде точне коригування animation-range, щоб привести значення в потрібний діапазон, а тоді трохи математики перетворює анімовані значення на позиції.

А що з висотою контейнера?

Це на перший погляд проста задача виявляється найскладнішою частиною всієї техніки. Тут маємо класичну ситуацію з height: auto, коли висоту визначає вміст і немає прямого способу отримати обчислену висоту.

Може здатися, що 100cqh спрацює так само, як 100cqw, але ні: якщо застосувати container: size, сітка просто «схлопнеться» і її висота стане нульовою. Причини цього автор не розкриває детально, але зазначає, що робота з висотою в CSS завжди пов'язана з труднощами.

Проте вихід є — доволі хитрий спосіб, що також базується на Scroll-Driven Animations. Детальний розбір цього трюку автор уже наводив у окремій статті «How to Get the Width/Height of Any Element in Only CSS», яка за обсягом приблизно дорівнює поточній і є хорошим продовженням для тих, хто хоче глибше розібратися зі Scroll-Driven Animations.

.container { --h: calc(1px/var(--_h)); overflow: hidden; position: relative; timeline-scope: --_h; animation: --_h linear; animation-timeline: --_h; animation-range: entry 100% exit 100%; } .container:before { content:""; position: absolute; height: 1px; top: 0; view-timeline: --_h y; } @keyframes --_h {0% {--_h:1} to {--_h:0}}

У фінальній демонстрації всі ці частини зібрані в один робочий приклад.

Висновок

Автор визнає, що частина читачів могла загубитися вже в розділі про реалізацію, подумавши: «Що це взагалі таке?! Це не той CSS, який я знаю!». Таке відчуття цілком зрозуміле, але CSS постійно розвивається новими механізмами, які вимагають від розробників нових мисленнєвих моделей для розв'язання задач.

Щоб по-справжньому розкрити можливості сучасного CSS, варто перестати сприймати його лише як набір властивостей і значень. Складні реалізації на чистому CSS цілком можливі, і ця стаття — доказ цього на практиці.

Джерело: Master.dev Blog