Липкий інтерфейс — це функція, яку помічають лише коли вона дає збій. Заголовок, що зникає під час прокрутки, бічна панель, яка відмовляється закріпитися, кнопка «липне» не до того елемента — зазвичай це відбувається перед релізом, під час демонстрації або коли ваш CEO «швидко перевіряє сайт на iPad».
CSS зробив sticky обманливо простим, але реальні системи повернули все на місце. Sticky залежить від скрол-контейнерів, containment, стекування, режимів макета та особливостей браузерів. Трактуйте це як проблему надійності: визначте бажану поведінку, простежте ланцюжок прокрутки, а потім приберіть пастки.
Виробнича модель мислення для sticky
position: sticky — це не fixed і не relative. Це гібрид: поводиться як relative до певного порога, потім поводиться як «фіксований всередині межі», і перестає бути липким, коли досягає кінця цієї межі.
Три правила, які важать більше за все інше
- Sticky прив’язується до найближчого скрол-контейнера (точніше: до найближчого предка, що створює scrolling box і обрізає overflow по цій осі). Якщо ви думаєте «вьюпорт», у вас вже проблеми.
- Sticky потребує порогу (
top,bottom,leftабоright) і цей поріг має сенс в осі, по якій ви скролите. - Sticky обмежується своїм контейнерним блоком. Він не підніметься вище кінця контейнера тільки тому, що ви цього попросили.
Типовий звіт про баг зі sticky говорить: «вчора працювало». Що змінилося вчора? Не sticky. Ланцюжок предків. Хтось обернув вміст у новий див з overflow: hidden, додав transform для анімації або переключив макет на flex/grid й випадково змінив поведінку min-size. Sticky не змінювався. Ваш containment змінився.
Визначте поведінку як SRE: що є «коректним»?
Запишіть явно:
- Який елемент має бути липким (і по якій осі).
- Де він має злипатися (
top: 0,top: 64pxпід глобальною навігацією тощо). - Відносно чого він має злипатися (вьюпорт проти секції сторінки проти тіла модалки).
- Коли він має припинити бути липким (кінець колонки статті, кінець області бічної панелі, низ модалки).
Це не театральна процедура. Це спосіб уникнути найпоширенішої ситуації «sticky бореться зі sticky»: глобальний заголовок липне до вьюпорту, локальний заголовок липне до внутрішнього скролу, і обидва претендують на ті самі пікселі. Браузер обере; ваш продуктовий відділ не буде в захваті від вибору.
Sticky і ланцюг прокрутки: знайдіть реальний скролер
Багато додатків більше не скролять документ. Вони скролять root-div (#app), контейнер макета (.shell) або тіло модалки. Це нормально — але sticky використовуватиме цей скролер, а не вьюпорт, якщо налаштування overflow створюють скрол-контейнер.
Якщо запам’ятати лише один принцип діагностики, пам’ятайте це: sticky не ламається випадково; він ламається детерміністично, бо скрол-контейнер і контейнерний блок — не ті, що ви думаєте.
Жарт 1/2: Sticky схожий на кота — якщо ви намагаєтесь його примусити, воно перестає співпрацювати і дивиться на вас, поки ви не зміните умови.
Цитата, бо операції мають підтвердження
Надія — не стратегія.
— парафраз ідеї, часто приписуваної інженерам та операторам у колах надійності.
Факти та історія, які пояснюють сьогоднішню дивність
Липкий інтерфейс виглядає по-сучасному, але обмеження — старі. Двигун верстки браузера десятиліттями домовлявся між «намалювати тут» і «не ламати прокрутку».
- Факт 1: Поведінка sticky існувала як розширення WebKit (
-webkit-sticky) до стандартизації, тому спадок Safari досі дає странності в крайніх випадках. - Факт 2: Ранні «липкі заголовки» часто реалізовувалися через JavaScript-обробники скролу, що оновлювали
position: fixed, що спричиняло трясіння через часті події скролу та постійні читання/записи DOM. - Факт 3: Зростання single-page app перемістило прокрутку у вкладені контейнери, щоб «оболонка додатка» була статичною — випадково створивши #1 причину, чому sticky «перестає працювати».
- Факт 4:
overflow: hiddenстав дефолтним clearfix та хаком «попередити відскок» у багатьох кодових базах. Він також обрізає й встановлює поведінку скролу/containment у способи, що заважають sticky. - Факт 5: Mobile Safari історично по-різному обробляв одиниці вьюпорта та динамічні тулбари, що робило «sticky + 100vh макети» надійним способом втратити пару годин.
- Факт 6: CSS-трансформи (
transform) створюють нові контейнерні блоки та стекові контексти; їх широко використовували для GPU-акселерації до того, як зрозуміли, що вони можуть змінювати поведінку containment для sticky. - Факт 7: Sticky всередині табличних елементів (
thead,th) мав нерівну підтримку протягом років; вона покращилася, але «табличний макет» все ще має окрему поведінку порівняно з блочним макетом. - Факт 8: Друк і пагінований медіа впливали на двигуни верстки раніше; sticky там не застосовується, але спадщина «фрагментації» та «containment» все ще впливає на те, що браузери вважають безпечними моделями позиціювання.
Перевірені патерни: заголовки, бокові панелі та таблиці
Sticky може бути нудним. Ви хочете нудне. Нудне відправляється у продакшн.
Патерн A: Глобальний липкий заголовок (прокрутка документа)
Використовуйте це, коли документ прокручується (без вкладених скролерів). Тримайте просто:
- Розмістіть заголовок близько до початку DOM.
- Надайте йому
position: stickyтаtop: 0. - Задайте фон і
z-index, який ви справді маєте на увазі.
Ключова деталь: Не покладайтеся на прозорість, якщо не хочете, щоб вміст просвічував під час прокрутки. Якщо все ж хочете прозорий ефект, додайте легкий backdrop filter і прийміть, що підтримка у браузерах має значення.
Патерн B: Липка бічна панель в межах секції сторінки
Класичний макет документації: ліва навігація, основний контент, права «на цій сторінці». Пастка: бічна панель липне відносно найближчого скрол-контейнера, а контейнер бічної панелі часто є flex-предком.
Робити:
- Нехай сторінка прокручується документом, коли це можливо.
- Переконайтеся, що ланцюжок предків бічної панелі не встановлює overflow по осі прокрутки.
- За потреби використайте
align-self: flex-start, щоб flex не растягував елемент таким чином, що порушує очікувану висоту.
Надійний підхід: обгорніть бічну панель у колонку, яка визначає бажану межу, і застосуйте sticky до внутрішнього елемента:
.sidebar-columnвизначає межі й правила щодо висоти..sidebar-innerє sticky зtop, встановленим під висоту заголовка.
Патерн C: Липкий заголовок всередині прокручуваного контейнера (модалки, панелі)
Тут sticky відмінно працює, бо липне до контейнера, а не до вьюпорту — саме те, що потрібно в модалці з власною прокруткою.
Робити:
- Зробіть тіло модалки скрол-контейнером (
overflow: auto). - Розміщуйте липкий елемент всередині цього скрол-контейнера (не над ним).
- Будьте явними щодо
top, фону та стекування.
Патерн D: Липкі заголовки таблиць
Коли потрібні липкі заголовки таблиць всередині скрол-коробки:
- Використайте обгортку div, що скролиться (
overflow: auto). - Застосуйте
position: sticky; top: 0до елементівth. - Задайте
backgroundдляth, щоб рядки не просвічувалися.
Перевірка реальності: Рендеринг таблиць може дивно поводитись із z-index та злиттям бордерів. Якщо потрібна пікабельна точність, розгляньте розділення заголовка і тіла в дві синхронізовані таблиці — але лише якщо готові це підтримувати.
Чому sticky ламається (режими відмов, з якими ви постійно стикаєтесь)
1) Неправильний скрол-контейнер
Якщо в предка є overflow: auto або overflow: hidden (або навіть overflow: clip), липкий елемент може виявитися обмеженим цим предком. Це #1 розбіжність між наміром («липнути до вьюпорту») і реальною поведінкою («липнути до цієї панелі»).
Як це виглядає: Sticky працює лише в маленькій області; він раніше припиняє липнути; або він ніколи не стає липким, бо контейнер фактично не скролиться.
2) Немає порогу (або поріг ефективно «відсутній»)
Sticky потребує порогу: top або bottom. Якщо ви його не задаєте, багато браузерів нічого значущого не зроблять. Якщо ви задаєте його в осі, яка ніколи не скролиться, теж нічого не станеться. Це нудно, але реально.
3) Предок створює контейнерний блок через transform/filter/perspective
Трансформи та деякі ефекти створюють нові контейнерні блоки та стекові контексти. Sticky чутливий до цього, бо браузеру потрібно вирішити, що означає «відносно» коли предок фактично нова система координат.
Типові винуватці:
transform: translateZ(0)як хак «GPU acceleration»filterабоbackdrop-filterна батьківському контейнеріperspectiveна обгортках макетаcontain: paintабо інші налаштування containment, що використовуються для ізоляції продуктивності
4) Лови flexbox та min-height
Sticky всередині flex-макетів може лаятися, коли поведінка висоти та overflow предка створює обмеження, яких ви не очікували. Класичний приклад — flex-контейнер з дефолтами min-height, що не дозволяють скролитися, тож sticky ніколи не досягає стану «липне».
Дві практичні поради:
- Якщо у вас макет на всю висоту з flex, часто потрібен
min-height: 0на дочірніх елементах flex, які повинні зменшуватися і скролитися. - Не ставте sticky на елемент, який розтягується так, що його «нормальна позиція» стає неоднозначною.
5) Сюрпризи з z-index і стековими контекстами
Sticky, який «працює», але прихований під контентом — це проблема стекування, а не sticky. Липкі елементи не автоматично плавають над усім. Якщо сусід створює новий стековий контекст з вищим z-index, ваш липкий заголовок виглядатиме так, ніби він зникає.
6) Зрушення макета та динамічний вміст
Sticky обчислюється відносно макета. Якщо контент підвантажується пізно (реклама, зображення без розмірів, асинхронні компоненти), «закріплена» позиція може стрибнути. Користувачі називають це «глюком»; ваші метрики — CLS.
7) Вкладені липкі елементи та конкуренція офсетів
Два липкі заголовки у вкладених скролерах можуть перекриватися. Браузер не помиляється; помиляєтесь ви. Вирішіть, хто за які пікселі відповідає, і узгодьте офсети явно.
Жарт 2/2: Кожного разу, коли ви вкладаєте скрол-контейнер, майбутній ви втрачає годину й набуває нової думки про «простий CSS».
Швидкий план діагностики
Це послідовність «припиніть гадання». Виконуйте її по черзі. Зазвичай ви знайдете проблему на третьому кроці.
Перший: підтвердьте скрол-контейнер
- Визначте, який елемент фактично скролиться:
document, app shell div, тіло модалки або панель. - Перевірте значення overflow у предків по шляху до липкого елемента.
- Якщо скрол-контейнер не той, що ви очікуєте, виправте це перед тим, як торкатися sticky.
Другий: підтвердьте передумови для sticky
- Липкий елемент має
position: stickyі поріг (зазвичайtop). - Липкий елемент знаходиться в тому скрол-контейнері, який ви очікуєте.
- Липкий елемент не обмежений занадто маленьким контейнерним блоком.
Третій: перевірте «вбивць sticky»
- Чи має будь-який предок
overflow: hidden/clipпо осі sticky. - Чи має будь-який предок
transform,filter,perspectiveабо containment, що змінює систему координат. - Чи заважає розмірювання flex/grid прокрутці (шукайте відсутній
min-height: 0абоmin-width: 0). - Чи ховає липкий елемент стекування/z-index за контентом.
Четвертий: відтворіть у мінімальному DOM
- Скопіюйте липкий елемент і його предків у мінімальну HTML-сторінку.
- Видаляйте стилі, аж поки sticky не почне працювати.
- Останнє видалення — це ваша корінна причина, а не «CSS зламався».
Практичні завдання: команди, вивід і рішення
Баги sticky — це frontend-помилки, але їх відлагодження виграє від виробничих звичок: інспектуйте, фіксуйте стан, порівнюйте середовища і зберігайте докази. Ось конкретні задачі, які ви можете виконати локально або в CI, щоб уникнути «працює на моїй машині» з sticky.
Завдання 1: Перевірте, що в деплойованому CSS є position: sticky
cr0x@server:~$ grep -R "position:\s*sticky" -n dist/assets | head
dist/assets/app.3d9c2.css:1842:.header{position:sticky;top:0;z-index:50}
dist/assets/app.3d9c2.css:9912:.toc{position:sticky;top:72px}
Що означає вивід: Ваш збірний бандл містить правила sticky і очікувані офсети.
Рішення: Якщо grep нічого не знаходить, ваш пайплайн збірки видалив або переписав правило (налаштування autoprefixer, екстракція CSS або крок «critical CSS»). Виправте вхідні файли збірки перед дебагом макета.
Завдання 2: Перевірте, чи «оптимізація» не додала широко overflow: hidden
cr0x@server:~$ grep -R "overflow:\s*hidden" -n src styles | head -n 20
src/layout/Shell.css:12:.shell{overflow:hidden;height:100vh}
src/components/Card.css:4:.card{overflow:hidden;border-radius:12px}
styles/utilities.css:88:.clip{overflow:hidden}
Що означає вивід: Обгортки макета обрізають overflow. Це головний підозрюваний у відмовах sticky, особливо якщо застосовано до кореневого shell.
Рішення: Якщо це на головному шляху прокрутки, видаліть або перемістіть його вниз по дереву лише на ті елементи, які потребують обрізання (карти, зображення).
Завдання 3: Підтвердьте, що прокрутка випадково не перейшла в app shell
cr0x@server:~$ grep -R "overflow:\s*auto" -n src/layout | head
src/layout/Shell.css:16:.content{overflow:auto;min-height:0}
Що означає вивід: Додаток скролиться всередині .content, а не документу. Sticky буде прив’язаний до цього контейнера.
Рішення: Розмістіть липкі елементи як дочірні .content (якщо вони мають липнути в ньому), або переробіть так, щоб документ скролився, якщо потрібен Sticky на рівні вьюпорту.
Завдання 4: Виявлення трансформ-хаків, що змінюють поведінку контейнерів
cr0x@server:~$ grep -R "transform:\s*translateZ(0)" -n src styles | head
src/layout/Shell.css:21:.shell{transform:translateZ(0)}
Що означає вивід: Хтось використав класичний «GPU nudge». Це може створити новий контейнерний блок/стековий контекст.
Рішення: Видаліть його, якщо не можете довести, що він вирішує реальну проблему з перформансом. Замініть на точкові трансформи для анімованих елементів, а не на весь shell.
Завдання 5: Перевірте налаштування containment, які обрізають або ізолюють малювання
cr0x@server:~$ grep -R "contain:" -n src styles | head
src/layout/Shell.css:22:.shell{contain:paint}
src/components/Grid.css:7:.grid{contain:layout paint}
Що означає вивід: Використовується containment. Це дійсна оптимізація, але вона також може змінювати, як нащадки позиціюються й малюються.
Рішення: Якщо sticky всередині ізольованого піддерева себе неправильно поводить, видаліть containment з предка або перерезультуйте так, щоб sticky був поза цим ізольованим регіоном.
Завдання 6: Доведіть, що липкий елемент закривається іншим контентом (аудит z-index)
cr0x@server:~$ grep -R "z-index" -n src/components src/layout | head -n 25
src/layout/Header.css:9:.header{z-index:10}
src/components/Modal.css:3:.backdrop{z-index:100}
src/components/Popover.css:5:.popover{z-index:200}
src/components/Content.css:44:.content{position:relative;z-index:50}
Що означає вивід: Ваш липкий заголовок має z-index: 10, але контент або оверлеї можуть бути вище за нього.
Рішення: Або підніміть z-index заголовка в правильному стековому контексті, або видаліть конкуренційні z-index у елементів, що не мають бути над глобальним заголовком.
Завдання 7: Виявити вкладені скролери на сторінці за допомогою Playwright (headless)
cr0x@server:~$ node -e "const { chromium } = require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage();await p.goto('http://localhost:3000');const scrollers=await p.$$eval('*', els=>els.filter(e=>{const s=getComputedStyle(e);return (s.overflowY==='auto'||s.overflowY==='scroll') && e.scrollHeight>e.clientHeight;}).slice(0,15).map(e=>({tag:e.tagName,id:e.id,cls:e.className,scrollH:e.scrollHeight,clientH:e.clientHeight,overflowY:getComputedStyle(e).overflowY})));console.log(scrollers);await b.close();})();"
[
{ tag: 'DIV', id: 'app', cls: 'shell', scrollH: 3120, clientH: 900, overflowY: 'auto' },
{ tag: 'DIV', id: '', cls: 'modal-body', scrollH: 1410, clientH: 520, overflowY: 'auto' }
]
Що означає вивід: App shell і тіло модалки — скрол-контейнери. Sticky всередині кожного буде прив’язаний до них.
Рішення: Визначте, який скролер відповідає за sticky. Якщо хочете липкість на рівні вьюпорту, перестаньте скролити #app і дайте прокручуватися документу.
Завдання 8: Захопіть обчислені стилі липкого елемента під час звіту про баг
cr0x@server:~$ node -e "const { chromium } = require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage();await p.goto('http://localhost:3000/docs');const sel='.toc';const s=await p.$eval(sel, el=>{const cs=getComputedStyle(el);return {position:cs.position,top:cs.top,overflow:cs.overflow,transform:cs.transform,zIndex:cs.zIndex};});console.log(s);await b.close();})();"
{ position: 'sticky', top: '72px', overflow: 'visible', transform: 'none', zIndex: 'auto' }
Що означає вивід: Елемент має sticky з валідним top; сам по собі без трансформацій.
Рішення: Якщо це виглядає правильно, проблема майже напевно в предку (overflow/transform/contain) або в стековому контексті (z-index «auto» плюс сусід із стековим контекстом).
Завдання 9: Автоматично виявити overflow/transform у предків для вибраного селектора
cr0x@server:~$ node -e "const { chromium } = require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage();await p.goto('http://localhost:3000/docs');const chain=await p.$eval('.toc', el=>{const out=[];let n=el.parentElement;let i=0;while(n&&i<12){const cs=getComputedStyle(n);out.push({tag:n.tagName,id:n.id,cls:n.className,overflowY:cs.overflowY,transform:cs.transform,contain:cs.contain});n=n.parentElement;i++;}return out;});console.log(chain);await b.close();})();"
[
{ tag: 'ASIDE', id: '', cls: 'toc-column', overflowY: 'visible', transform: 'none', contain: 'none' },
{ tag: 'DIV', id: '', cls: 'content', overflowY: 'auto', transform: 'none', contain: 'none' },
{ tag: 'DIV', id: 'app', cls: 'shell', overflowY: 'hidden', transform: 'translateZ(0)', contain: 'paint' }
]
Що означає вивід: Ваш липкий елемент знаходиться в .content (скрол-контейнер), тоді як #app обрізає overflow і застосовує transform/containment. Це — полігон мін.
Рішення: Приберіть overflow:hidden з #app, відкиньте transform-хак або перемістіть sticky поза це піддерево.
Завдання 10: Підтвердіть, що елемент дійсно досягає порогу (тест прокрутки)
cr0x@server:~$ node -e "const { chromium } = require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage();await p.goto('http://localhost:3000/docs');await p.evaluate(()=>{const sc=document.querySelector('.content');sc.scrollTop=0;});const before=await p.$eval('.toc', el=>el.getBoundingClientRect().top);await p.evaluate(()=>{const sc=document.querySelector('.content');sc.scrollTop=400;});const after=await p.$eval('.toc', el=>el.getBoundingClientRect().top);console.log({before,after});await b.close();})();"
{ before: 184, after: 72 }
Що означає вивід: Топ елемента змінився на 72px після прокрутки: sticky правильно увімкнувся в цьому скролері.
Рішення: Якщо after продовжує змінюватися (не фіксується), sticky не активується — шукайте overflow/transform-обмеження або відсутній top.
Завдання 11: Зафіксуйте зрушення макета, що роблять sticky ривками (CLS-проксі через порівняння скріншотів)
cr0x@server:~$ node -e "const { chromium } = require('playwright');(async()=>{const b=await chromium.launch();const p=await b.newPage({viewport:{width:1280,height:720}});await p.goto('http://localhost:3000/docs');await p.waitForTimeout(200);await p.screenshot({path:'s1.png',fullPage:false});await p.waitForTimeout(2000);await p.screenshot({path:'s2.png',fullPage:false});console.log('captured s1.png and s2.png');await b.close();})();"
captured s1.png and s2.png
Що означає вивід: Зроблено два скріншоти рано й після того, як асинхронний контент ймовірно завантажився.
Рішення: Якщо позиції заголовка/TOC відрізняються між знімками, у вас є пізнє завантаження контенту або заміна шрифтів, що зсуває макет. Виправте резервування місця (явні розміри зображень, стратегія font-display, skeleton-и).
Завдання 12: Перевірте правила overflow і висоти в збудованому CSS для вашого shell
cr0x@server:~$ sed -n '1,120p' src/layout/Shell.css
.shell{
height:100vh;
overflow:hidden;
transform:translateZ(0);
contain:paint;
}
.content{
display:flex;
overflow:auto;
min-height:0;
}
Що означає вивід: Shell — обрізаний, трансформований, paint-contained блок. Content скролиться всередині нього.
Рішення: Якщо ви хочете липкий заголовок відносно вьюпорту, ця архітектура вам протирічить. Або дайте прокрутку документу, або прийміть container-sticky і спроектуйте офсети відповідно.
Завдання 13: Перевірте, чи регресія корелює з недавньою зміною (git blame з наміром)
cr0x@server:~$ git blame -L 1,40 src/layout/Shell.css
a81c9d12 (devA 2025-10-03 10:14:02 +0000 1) .shell{
a81c9d12 (devA 2025-10-03 10:14:02 +0000 2) height:100vh;
a81c9d12 (devA 2025-10-03 10:14:02 +0000 3) overflow:hidden;
a81c9d12 (devA 2025-10-03 10:14:02 +0000 4) transform:translateZ(0);
a81c9d12 (devA 2025-10-03 10:14:02 +0000 5) contain:paint;
a81c9d12 (devA 2025-10-03 10:14:02 +0000 6) }
Що означає вивід: Один коміт ввів кілька «вбивць sticky» одразу.
Рішення: Відкотіть або розбийте зміну. Якщо мотивація — перформанс, проведіть бенчмарк і поверніть лише те, що можна обґрунтувати.
Завдання 14: Підтвердіть, що липкий елемент не всередині обрізаючого предка (дамп DOM)
cr0x@server:~$ node -e "const { JSDOM } = require('jsdom');const fs=require('fs');const html=fs.readFileSync('dist/index.html','utf8');const dom=new JSDOM(html);const el=dom.window.document.querySelector('.header');let n=el;let i=0;while(n&&i<8){console.log(i,n.tagName,n.id,n.className);n=n.parentElement;i++;}"
0 HEADER header
1 DIV app shell
2 BODY
3 HTML
Що означає вивід: Заголовок є дитиною трансформованого/обрізаного shell.
Рішення: Якщо shell не має бути шаром containment/clip, переробіть структуру: розмістіть глобальний заголовок як сусіда до скрол-контейнера або приберіть shell-рівне обрізання.
Помітили шаблон? Ми не «намагаємося випадковий CSS». Ми доводимо, що скролиться, що містить, що обрізає й що стекується. Sticky працює, коли ви ставитеся до DOM як до системи.
Три корпоративні міні-історії з польових боїв за sticky
Міні-історія 1: Інцидент через невірне припущення
У компанії був консоль підтримки з липкою панеллю «статус справи» вгорі основної панелі. Вона працювала місяцями. Потім зайшов редизайн: консоль перенесли в новий app shell з постійною лівою навігацією і «поліпшенням продуктивності», яке запобігло прокручуванню документа. Прокрутка перейшла в .content.
Репорти від сапорту почали приходити: панель статусу іноді зникала при прокрутці довгих справ. Дехто думав, що це баг з правами, бо це траплялося частіше на довгих справах (які потребували довшої прокрутки). Тріаж позначив баг як «періодичний рендеринг». Така мітка мала б бути забороненою.
Неправильне припущення: «sticky липне до вьюпорту». Не липла. Вона липла до найближчого скрол-контейнера, яким тепер був вкладений див. Але панель статусу більше не була в тому диві — вона була сусідом. Отже вона ніколи не входила в стан. На деяких екранах виглядало «нормально», бо панель випадково залишалася видимою через макет і висоту вьюпорта.
Виправлення не було екзотичним: або перемістити панель у скрол-контейнер і зробити її там липкою, або дозволити документу прокручуватись і залишити її липкою до вьюпорту. Вони обрали перше. Інцидент закінчився, і команда оновила гайдлайни макета: хто створює скрол-контейнер — той відповідає за поведінку sticky. Без обговорень.
Міні-історія 2: Оптимізація, що відкотилася
Інша команда зробила знаннєву базу зі липким TOC «На цій сторінці». Все було чисто, швидко і стабільно — доки хтось не запустив проект «зменшити вартість paint». Вони посипали contain: paint по регіонах макета і додали transform: translateZ(0) кореневому контейнеру, щоб «підняти його на окремий шар».
Вони виміряли покращення в мікробенчмарку: синтетичний тест прокрутки на одному середньому Android-пристрої. Потім запустили в продакшн. Через тиждень — баг-репорти: TOC перестав липнути в Safari, а заголовок іноді рендерився під контентом під час прокрутки. Це виглядало як проблема z-index, але не завжди вирішувалося зміною z-index.
Корінь проблеми — коктейль: трансформований корінь створив новий контейнерний блок і стековий контекст; paint containment змінив, як нащадки обрізаються й малюються; і липкий елемент тепер був в піддереві, яке браузер по-іншому обробляв під час скрол-композитингу. Різні рушії по-різному реагували на комбінацію.
Вони відкотили «оптимізацію», потім вибірково повернули її: лише для компонентів, що реально анімувалися, а не для кореня. Продуктивність залишилася гарною, sticky знову стала нудною, і команда засвоїла урок від операцій: оптимізувати без перевірки коректності — означає ламати речі ефективно.
Міні-історія 3: Нудна, але правильна практика, що врятувала день
Фінтех-дешборд мав кілька шарів sticky: глобальний заголовок, саб-навігацію і липкий заголовок таблиці всередині прокручуваної сітки. Це саме той UI, що гарно виглядає у мокапі і перетворюється на ферму багів у продакшн.
Замість «спробуй і подивись» команда написала невеликий набір автоматизованих тестів макета з Playwright. Не модні візуальні снапшоти — а твердження геометрії: ідентифікація скрол-контейнера, перевірки boundingClientRect після прокрутки і sanity-check, що топ липкого заголовка дорівнює очікуваному офсету.
Місяць потому рефактор змінив обгортку на overflow: hidden, щоб виправити проблему з круглими кутами. Sticky-тести в CI впали одразу. Розробник побачив помилку, перемістив обрізання в дочірній елемент і зберіг поведінку прокрутки. Жоден користувач не помітив. Ніхто не писав великий тред у Slack про Safari.
Це нецікава правда: надійність sticky приходить від запобіжників. Ваш майбутній я не згадає, чому min-height: 0 був важливий у тому flex-дитині. Тести згадають.
Поширені помилки: симптом → корінна причина → виправлення
Sticky ніколи не активується (прокручується як звичайний)
Симптом: Елемент поводиться як position: relative; ніколи не зупиняється у верхній частині.
Корінна причина: Відсутній top/bottom, або липкий елемент не в тому скрол-боксі, який ви думаєте.
Виправлення: Додайте top: 0 (або правильний офсет). Підтвердіть скролер аудитом ланцюжка предків. Перемістіть sticky всередину скрол-контейнера.
Sticky працює, але припиняє занадто рано
Симптом: Елемент коротко липне, потім знову прокручується ще до кінця секції.
Корінна причина: Контейнерний блок (часто батько) коротший за скролюваний вміст, тож sticky обмежується і досягає кінця контейнера.
Виправлення: Зробіть так, щоб елемент-границя обгортав всю область, де має працювати sticky. Уникайте застосування sticky до елемента, батько якого несподівано має обмежену висоту.
Sticky працює, але прихований під контентом
Симптом: Елемент «є», але контент прокручується поверх нього.
Корінна причина: Проблеми стекування та z-index (часто викликані transform або позиціонованими елементами з z-index).
Виправлення: Дайте липкому елементу невластивий auto z-index в правильному стековому контексті. Приберіть зайві z-index у сусідів. Уникайте root-level transform, що створюють стекові контексти.
Sticky мерехтить або дрижить під час прокрутки
Симптом: Під час прокрутки липкий елемент тремтить, перевідмальовується або зсувається на піксель.
Корінна причина: Субпіксельне округлення + зміни композитингу + динамічні зміни контенту; іноді посилюється через завантаження шрифтів або backdrop-filter.
Виправлення: Зменшіть складність композитингу: уникайте поєднання sticky з важкими ефектами на предках. Забезпечте резервування простору для шрифтів/зображень, щоб уникнути пізніх зсувів макета.
Sticky ламається лише в модалці
Симптом: Працює на сторінці, не працює всередині модальних діалогів.
Корінна причина: Тіло модалки — скролер; липкий елемент поза ним, або предок обрізає overflow.
Виправлення: Розміщуйте липкі заголовки всередині прокручуваного тіла модалки. Явно зробіть тіло модалки скрол-контейнером.
Липка бічна панель перекриває футер або іншу секцію
Симптом: Бічна панель продовжує липнути і закриває контент біля низу.
Корінна причина: Межа sticky занадто велика (наприклад, контейнер бічної панелі охоплює всю сторінку), або офсети не враховують нижній контент.
Виправлення: Обмежте контейнерний блок бічної панелі до секції, де вона має липнути. Розгляньте position: sticky; bottom: ... для певних сценаріїв, але робіть це свідомо.
Sticky ламається лише в Safari / iOS
Симптом: Працює в Chromium/Firefox, але не в Safari.
Корінна причина: Комбінація вкладеної прокрутки, трансформів і ефектів; або поведінка одиниць вьюпорта, що змінює пороги.
Виправлення: Приберіть трансформи/containment з предків, спростіть скрол-контейнери і валідируйте за допомогою автоматизованих геометричних перевірок у WebKit. Віддавайте перевагу container-sticky всередині одного overflow-скролера для складних оболонок.
Sticky всередині flex-колонки поводиться несподівано
Симптом: Він липне, але не в потрібний момент; або не липне, коли контент короткий.
Корінна причина: Розміри flex і дефолти min-size не дають прокрутитися або змінюють висоту контейнерного блоку.
Виправлення: Додайте min-height: 0 до flex-дітей, що повинні скролитися; переконайтеся, що контейнер липкого елемента має правильну висоту та налаштування overflow.
Чеклісти / покроковий план
Чекліст: липкий заголовок, що має липнути до вьюпорту
- Забезпечте прокрутку документа (уникати прокрутки всередині
#app, якщо це не необхідно). - Переконайтеся, що жоден предок липкого заголовка не встановлює вертикальний overflow (
hidden,clip,auto), якщо ви не плануєте container-sticky. - Уникайте root-level
transform,filter,containна обгортках, що містять заголовок. - Встановіть
position: sticky; top: 0на заголовку. - Задайте фон і усвідомлений
z-index. - Перевірте поведінку автоматизованим прокручуванням та твердженням boundingClientRect.
Чекліст: липка бічна панель, що має припинятися в кінці секції
- Створіть обгортку, що визначає межі бічної панелі (колонка секції).
- Розмістіть усередині обгортки липкий внутрішній елемент.
- Встановіть
top, щоб очистити місце під глобальним заголовком (не гадати; використовуйте токен або CSS-перемінну). - Переконайтеся, що обгортка не має overflow, що обрізає по осі sticky.
- Якщо використовуєте flex/grid, перевірте, що висота обгортки відповідає очікуваній висоті секції.
- Тестуйте з довгим і коротким контентом, а також з пізнім підвантаженням контенту.
Чекліст: липкість всередині модалки або панелі-скролера
- Зробіть один елемент скрол-контейнером:
overflow: autoна тілі модалки. - Розміщуйте липкі заголовки всередині цього скрол-контейнера.
- Переконайтеся, що поріг sticky враховує фіксовану рамку модалки.
- Зберігайте трансформи/ефекти на мінімумі у предків; застосовуйте ефекти до сусідів, а не до батьків, якщо можливо.
- Тестуйте у WebKit, якщо відвантажуєте на iOS.
Покроково: коли sticky зламався і вам потрібно виправити сьогодні
- Знайдіть скрол-контейнер за допомогою автоматичного сканування (як task Playwright для виявлення скролерів). Вирішіть, чи це правильно.
- Роздрукуйте ланцюжок предків для липкого елемента і шукайте overflow, transform, contain, filter.
- Тимчасово видаліть підозрілі властивості (в devtools), починаючи з найближчого предка назовні.
- Виправте архітектуру: припиніть вкладати скролери, або прийміть container-sticky і перемістіть вузол sticky всередину скролера.
- Зафіксуйте це геометричним тестом, щоб той самий баг не повернувся в наступному спринті під іншою зовнішністю.
Питання й відповіді
1) Чому position: sticky перестає працювати, коли я додаю overflow: hidden батьку?
Тому що обрізання overflow змінює контекст прокрутки/контейнер, від якого залежить sticky. У багатьох випадках воно обмежує sticky до коробки предка або перешкоджає необхідному відношенню прокрутки. Перемістіть обрізання overflow на нижчий рівень елемента (наприклад, на візуальну картку) замість обгортки макета.
2) Sticky відносний до вьюпорту чи сторінки?
За замовчуванням ні до того, ні до іншого. Sticky відноситься до найближчого релевантного скрол-контейнера для цієї осі. Якщо документ прокручується і жоден предок не створює контекст прокрутки/обрізання, він виглядатиме як прив’язаний до вьюпорту.
3) Коли варто використовувати position: fixed замість sticky?
Використовуйте fixed, коли елемент має залишатися прикріпленим до вьюпорту незалежно від місця в DOM, і ви готові самостійно керувати відступами (padding/margins). Використовуйте sticky, коли елемент має липнути лише в межах секції або контейнера.
4) Чому мій липкий заголовок перекриває контент?
Sticky не резервує простір так само, як статичний заголовок; він змінює позицію під час прокрутки. Додайте контенту верхній паддінг/маргін рівний висоті заголовка, якщо заголовок накладається. Також переконайтеся, що заголовок має фон і правильне стекування для візуальної цілісності.
5) Чи можна мати два липкі заголовки (глобальний + локальний)?
Так, але потрібно координувати офсети. Локальний липкий елемент повинен мати top, що враховує висоту глобального sticky. Якщо вони в різних скрол-контейнерах, перегляньте макет — вкладений sticky через вкладені скролери — це місце, де баги народжуються.
6) Чому sticky поводиться інакше в Safari?
Safari чутливіший до комбінацій вкладеної прокрутки, трансформів і ефектів, що створюють нові контейнерні блоки або шарування. Практичне виправлення — архітектурне: зменшити вкладеність скролерів і уникати трансформів/containment на предках sticky.
7) Чи працює z-index на липких елементах?
Так, але тільки в межах відповідного стекового контексту. Якщо предок створює новий стековий контекст (часто через transform або позиціонований елемент з z-index), ви можете піднімати z-index як завгодно і все одно програти сусідньому елементу в іншому контексті. Спочатку виправте стекові контексти, потім z-index.
8) Чому sticky не працює всередині flex-колонки як очікується?
Часто тому, що елемент, який має скролитися, фактично не скролиться: у flex-елементів є дефолтні мінімальні розміри, які можуть заважати зменшенню, тож контейнер не отримує scrollbar. Додайте min-height: 0 потрібним flex-дітям і перевірте налаштування overflow.
9) Чи виправдано використовувати JavaScript-реалізацію sticky (слухачі scroll)?
Іноді — коли потрібна поведінка, яку CSS не може виразити (складне виявлення колізій, снапування, динамічні межі). Але трактуйте це як фічу продуктивності з бюджетом: використовуйте IntersectionObserver коли можливо, уникайте читань/записів макета на кадр і тестуйте на недорогих пристроях.
10) Як запобігти jitter sticky через динамічний контент?
Резервуйте простір: явні розміри зображень, стабільна стратегія завантаження шрифтів і уникайте вставки DOM над липкими регіонами після первинного рендера. Якщо контент має змінюватися, розгляньте анімацію змін висоти обережно і перевіряйте обчислений top липкого елемента під час тестів прокрутки.
Наступні кроки, які ви реально можете зробити
Sticky працює, коли ви перестаєте сприймати його як магію і починаєте трактувати як систему з обмеженнями: скрол-контейнери, контейнерні блоки і стекові контексти. Визначте, де живе прокрутка, розмістіть sticky всередині цього контексту і приберіть «допоміжні» стилі обгорток, що тихо змінюють системи координат.
Зробіть це далі, по порядку:
- Виберіть один основний скролер для кожного досвіду (сторінка, модалка, панель). Уникайте вкладення, якщо немає вагомої причини.
- Запустіть аудит ланцюжка предків для кожного липкого елемента (overflow, transform, contain, filter, z-index).
- Уніфікуйте офсети sticky за допомогою CSS-перемінних (висоту заголовка не варто вгадувати).
- Додайте один автоматизований геометричний тест на компонент sticky: прокрути, виміряй boundingClientRect, стверди поріг sticky.
Якщо хочете sticky без болю, будуйте його як продакшн-системи: зробіть середовище передбачуваним, вимірюйте, що браузер робить, і приберіть підводні камені раніше, ніж вони відберуть у вас вихідні дні.