Липкий хедер, що ховається при прокручуванні: підхід «спочатку CSS» + мінімальний JS‑фолбек
Ви випустили липкий хедер. Маркетинг був у захваті. Користувачі — ні. Тепер кожне прокручування відчувається як штовхання холодильника вгору по схилу: джанк, ривки й іноді перекриття тієї самої частини інтерфейсу, до якої хедер мав допомогти дістатися.
Мета проста: хедер має бути доступним, коли користувач скролить вгору (корисно), і ховатися, коли скролить вниз (ввічливо). Робимо це з першою чергою за CSS, бо CSS стабільний під навантаженням. Потім додаємо крихітний JavaScript‑фолбек для того, чого CSS не може визначити: напрям прокрутки і «чи ми взагалі ще біля верху».
Чого ви насправді хочете (і чого — ні)
«Липкий хедер, що ховається при прокручуванні» звучить як дизайнерський прийом. У продакшні це контур керування. Ви вимірюєте положення скролу, робите висновок про намір користувача і змінюєте макет або відмалювання у відповідь. Це проблема надійності в плащі.
Ось практична специфіка, яку я рекомендую:
- На вершині сторінки: хедер видимий, без підвищення (без тіні), без «відскоків».
- При скролі вниз: хедер ховається після невеликого порога (щоб уникнути мерехтіння при дрібних рухах).
- При скролі вгору: хедер швидко зʼявляється (навігація і пошук знову корисні).
- Якірні посилання / навігація в межах сторінки: заголовки контенту не повинні закриватися хедером.
- Reduced motion: ніяких анімацій ковзання, якщо ОС попросила менше руху.
- Mobile Safari: без тремтіння, без блокування натисків, з урахуванням безпечних зон.
- Нульовий бюджет CLS: хедер не має викликати зсув макета після рендеру.
Чого ви не хочете:
- Ховати/показувати шляхом зміни
heightабоdisplayпід час скролу. Це робота з макетом. Ви платите за неї на кожному кадрі. - Обробник скролу, який робить більше, ніж встановлює булеве значення. Браузер уже має повноцінну роботу з відрисовування пікселів.
- «Працює на моєму MacBook Pro» як критерій продуктивності. Середній телефон не переймається вашими почуттями.
Одне правило для коду‑ревʼю: анімуйте трансформації, а не макет. Якщо реалізація призводить до трешу в макеті, вона рано чи пізно зустріне сторінку з важким контентом і програє.
Факти та коротка історія: чому це складніше, ніж здається
Липкі хедери здаються вирішеною проблемою, бо ви бачили їх тисячі разів. Під капотом це рукостискання між механікою прокрутки, композитингом, особливостями вьюпорту і очікуваннями доступності. Кілька швидких контекстних пунктів, що важливі, коли ви дебагуєте це о 2‑й ночі:
position: stickyстандартизували після років вендорних експериментів. Раніше «sticky» часто полягав на JS‑поліфілах із прослуховуванням скролу.- Мобільні браузери фактично мають два вьюпорти. Лейаут‑вьюпорт і візуальний вьюпорт можуть відрізнятися (особливо при згортанні адресного рядка), що впливає на очікування щодо «top: 0».
- iOS Safari історично мав проблеми з fixed/sticky під час rubber‑band прокрутки. Пересування, overscroll і динамічна панель інструментів можуть давати крайові випадки тремтіння.
- Події скролу колись були синхронними й дорогими. Браузери перейшли на асинхронну прокрутку, тому сучасні обробники скролу мають бути легкими і часто passive.
IntersectionObserverзʼявився, щоб уникнути постійного опитування при скролі. Це велика річ для логіки «я пройшов сентинел?» без навантаження ЦП на кожен піксель.- Core Web Vitals поставив цифри за відчуттям. CLS і INP викриють вас, навіть якщо QA пропустив тремтіння.
- Існує «scroll anchoring», щоб уникнути стрибків контенту. Але хедери, що змінюють висоту, можуть зламати його й повернути стрибки, які користувачі сприймають як поломку.
- Safe area insets стали проблемою веба з телефоном із вирізом. Якщо ваш хедер ігнорує
env(safe-area-inset-top), ви отримаєте обрізаний контент на деяких пристроях.
Жарт №1: Липкий хедер — як квота на сховище: ніхто не помічає, поки не ламається, а потім це одразу найвищий пріоритет у всіх.
Базовий підхід CSS: sticky, безпечні відступи та без зсувів макета
Підхід «спочатку CSS» означає: отримайте 80% поведінки без JavaScript. Це дає стабільну базу: без залежності від обробників скролу, без сюрпризів, коли головний потік зайнятий, і менше випадків «працює тільки на цій одній сторінці».
Базовий CSS для хедера (sticky + анімація трансформації)
Використовуйте position: sticky і тримайте висоту хедера сталою. При хованні перекладайте його поза вікно за допомогою transform. Це дружньо до композитора і зазвичай уникає перерахунку макета.
Хедер на цій сторінці вже реалізує базу: sticky‑позицiя, постійна висота, ховання через трансформацію, padding для safe‑area і :target scroll‑margin, щоб уникнути перекриття якорів.
Уникніть того, щоб якорні цілі приховувалися під хедером
Якщо ваша навігація в межах сторінки використовує цілі через #hash (посилання TOC, «перейти до розділу»), браузер прокручує ціль до верху. З липким хедером «верх» часто за знаходиться під плитою UI.
Просте й надійне рішення — scroll-margin-top для заголовків або глобальне правило :target. Саме це ми використовуємо:
cr0x@server:~$ cat ui.css | sed -n '1,40p'
:target {
scroll-margin-top: calc(var(--header-h) + 16px);
}
Що це означає: ви додаєте відступ під час прокручування для будь‑якого елемента, який стає ціллю фрагментної навігації. Рішення: застосовуйте це глобально, якщо структура документа послідовна; інакше обмежте до h2, h3, щоб уникнути дивних відступів на інших цілях.
Не допускайте стрибків контенту, коли хедер «ховається»
Якщо ховання змінює висоту хедера, контент піднімається вгору. Це класичний CLS. Користувачі сприймають це як «сайт рухається під моїм пальцем». Тримайте висоту сталою. Ховайте через трансформу.
Це також уникає режиму збоїв, коли хедер ховається, контент зсувається, і браузер намагається зберегти поточний якір прокрутки видимим, спричиняючи додаткові стрибки. Це ніби два контролери борються в повітрі.
Повага до reduced motion за замовчуванням
Ковзні хедери можуть провокувати рух у деяких користувачів. Робіть їх миттєвими при prefers-reduced-motion: reduce. Ви все ще можете перемикати видимість; просто пропустіть анімований перехід.
Три життєздатні патерни (виберіть свідомо)
Патерн A: «Завжди видимий sticky» (тільки CSS, нудно, надійно)
Це не те, що обіцяє тема, але це базова версія, з якої слід почати. Липкий хедер, який ніколи не ховається, має менше рухомих частин і зазвичай кращу доступність. Якщо ваш хедер високий або контент щільний, це може бути найкращим продуктним рішенням.
- Плюси: найпростіше, найменше джанку, легше зрозуміти.
- Мінуси: займає вертикальний простір, особливо болісно на малих екранах.
Патерн B: «Ховати при скролі вниз, показувати при скролі вгору» (мінімальний JS, найкращий загалом)
Це стандартна поведінка, якої очікують користувачі, бо відповідає намірам: якщо читають вниз — хедер дратує; якщо змінюють напрям — швидше за все хочуть навігацію.
- Плюси: працює скрізь, передбачувано, можна налаштувати пороги.
- Мінуси: потребує JS для визначення напрямку; треба бути обережним щодо продуктивності.
Патерн C: «Ховати після сентинела, показувати біля верху» (IntersectionObserver + опціональний напрям)
Якщо ви не любите слухачів скролу (розумно), поставте елемент‑сентінел поблизу верху. Коли він виходить з вікна, піднімайте хедер (тінь) і опціонально вмикайте логіку ховання. Це робить стан «на верху сторінки» надійним.
- Плюси: менше обчислень при скролі; стабільне визначення «чи ми на верху».
- Мінуси: все одно потребує визначення напрямку для повної поведінки hide‑on‑down; може ускладнитися при динамічних панелях.
Мінімальний JS‑фолбек: напрям, пороги та стан
CSS не може знати напрям прокрутки. Він може реагувати на стан, який ви встановите. Тож правильна форма: JS читає позицію скролу, встановлює кілька data‑атрибутів і йде в сторону.
Збережіть машину станів крихітною:
data-hidden: булевеdata-elevated: булеве (тінь, коли ви не на верху)
Продакшн‑ґрейд крихітний скрипт
Це мінімальний JS, який я готовий захищати на ревʼю продуктивності. Він використовує requestAnimationFrame для коалесценції подій скролу, поріг, щоб уникнути мерехтіння, і уникає роботи, коли нічого не змінилося.
cr0x@server:~$ cat sticky-header.js
(() => {
const header = document.querySelector('.site-header');
if (!header) return;
const hideThreshold = 12; // px of downward movement before hiding
const showThreshold = 6; // px of upward movement before showing
const elevateAfter = 4; // px from top before adding shadow
const topSnap = 0; // treat 0 as top; adjust for visual viewport if needed
let lastY = window.scrollY || 0;
let lastDir = 0; // -1 up, +1 down
let rafPending = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function update() {
rafPending = false;
const y = window.scrollY || 0;
const dy = y - lastY;
// Determine direction with deadzone to avoid noise.
let dir = lastDir;
if (dy >= hideThreshold) dir = 1;
else if (dy <= -showThreshold) dir = -1;
// Elevated when not at top.
const elevated = y > elevateAfter;
// Hide only when scrolling down and not near top.
let hidden = header.dataset.hidden === 'true';
if (y <= topSnap) {
hidden = false;
} else if (dir === 1) {
hidden = true;
} else if (dir === -1) {
hidden = false;
}
// Apply only on changes.
if ((header.dataset.elevated === 'true') !== elevated) {
header.dataset.elevated = elevated ? 'true' : 'false';
}
if ((header.dataset.hidden === 'true') !== hidden) {
header.dataset.hidden = hidden ? 'true' : 'false';
}
lastY = y;
lastDir = dir;
}
function onScroll() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(update);
}
window.addEventListener('scroll', onScroll, { passive: true });
// Run once on load in case the page loads mid-scroll.
update();
})();
Що це означає: тепер у вас детермінований перемикач, який не запуститься більше ніж раз за кадр анімації. Рішення: використовуйте цю саме форму, якщо вам важлива продуктивність; не давайте їй розростатися.
IntersectionObserver‑сентінел: надійний «стан верху» без здогадок
Найдивніші баги у цих хедерах походять від логіки «ми на верху?» під мобільними тулбарами. Елемент‑сентінел на початку контенту дає чіткий сигнал: якщо він перетинається, ви біля верху.
Ви можете комбінувати сентінел з вищенаведеним скриптом напряму, або використовувати його лише для elevation і уникнути мерехтіння тіні.
cr0x@server:~$ cat sentinel.js
(() => {
const header = document.querySelector('.site-header');
const sentinel = document.querySelector('[data-top-sentinel]');
if (!header || !sentinel || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver((entries) => {
const e = entries[0];
// When sentinel is visible, we're near the top: no shadow.
header.dataset.elevated = e.isIntersecting ? 'false' : 'true';
}, { root: null, threshold: [0, 1] });
io.observe(sentinel);
})();
Що це означає: стан elevation тепер керується перетином, а не евристикою scrollY. Рішення: віддавайте перевагу цьому підходу, якщо у вас є динамічні топ‑банери, колапсуючі панелі або складний макет, де «верх» ≠ scrollY==0.
Жарт №2: Якщо ви підключите три слухачі скролу, браузер не «розпаралелить» їх, він просто тихо засудить вас.
Доступність і правила «не ламайте кнопку назад»
Ховання хедера — це UX‑вибір. Якщо реалізувати його недбало, воно стане дефектом доступності.
Клавіатура і фокус: ніколи не ховайте сфокусовані елементи
Якщо в хедері є поле пошуку або навігаційні посилання, користувач може перейти туди через таб. Якщо ваш скрипт ховає хедер, поки в ньому є фокус, ви створите взаємодію «ось воно — і немає», ворожу до користувачів клавіатури.
Виправлення: якщо header.contains(document.activeElement) повертає true, примусово тримайте хедер видимим. Це невелика умова, яка запобігає неочікуваній проблемі.
cr0x@server:~$ rg "activeElement" -n sticky-header.js
Що це означає: якщо немає результатів, ви не додали захист фокуса. Рішення: додайте його, якщо у хедері є інтерактивні елементи (зазвичай так).
Екранні рідери: не видаляйте контент із дерева доступності
Зсув хедера через transform залишає його в DOM і в дереві доступності. Це зазвичай нормально. Не встановлюйте display: none як механізм «приховування», якщо ви не готові обробляти фокус, aria‑стан і наслідки ререндеру.
Якщо ви мусите повністю приховати елемент, використовуйте обережне управління фокусом і розгляньте inert (там, де підтримується), щоб заборонити табування в оф‑скрін контролах. Але тоді ви будуєте UI‑фреймворк. Краще цього уникати.
Reduced motion — не опція
Ви вже бачили CSS. Дотримуйтеся цього. Також розгляньте можливість вимкнення поведінки hide/show повністю при reduced motion, якщо продукт дозволяє. Ковзні UI, що реагують на скрол, можуть створювати відчуття «живого» сайту. Дехто цього не хоче.
Не ламайте вбудоване відновлення скролу браузера
Браузер намагається відновити позицію прокрутки при навігації назад/вперед. Якщо хедер змінює висоту або викликає макет під час початкового рендеру, ви можете отримати дивний «відновлено не в те місце, потім стрибок».
Краща практика: тримайте макет хедера стабільним з першого кадру. Уникайте пізнього підвантаження великого вебшрифту, який змінює висоту хедера. Якщо не можете уникнути — задайте явні висоти і використовуйте font‑display стратегії, що не ререндерять хедер.
Продуктивність: звідки береться джанк (і як його вбити)
Прокрутка має бюджет. Браузер прагне ~60fps на типових пристроях. Це дає приблизно 16ms на кадр, і цей час ділиться між усім: макетом, відмалюванням, композитингом, JS, зображеннями, рекламою, аналітикою тощо.
Чому трансформації зазвичай перемагають
Анімація через transform: translateY() часто опрацьовується ниткою композитора. Тобто вона може рухатися навіть якщо головний потік зайнятий. «Часто» — тут є нюанс; вам все одно потрібно уникати примусових перерахунків макета і важких пермалювань.
Три основні джерела джанку для хедеров, що ховаються
- Треш у макеті: перемикання властивостей типу
height,topабо класів, що рефлоять сторінку на кожному заході скролу. - Перевантаження головного потоку: обробник скролу робить занадто багато або тригерить повторні перерахунки стилів.
- Шторм фарбування: тіні, блюри й напівпрозорі фони, що перемальовують великі області при трансформації на слабких GPU.
Робіть тіні умовними, а не постійними
Велика тінь виглядає круто. Вона також може бути дорогою, особливо на мобільних. Застосовуйте її тільки коли ви не на верху, і подумайте про спрощену тінь для слабких пристроїв. Атрибут «elevated» дає чисте переключення.
Використовуйте passive‑слухачі і requestAnimationFrame
Passive слушачі скролу кажуть браузеру, що ви не викликатимете preventDefault(), тож він може прокручувати без очікування JS. Батчинг через requestAnimationFrame не дає вам робити зайву роботу між кадрами.
«Надія — не стратегія.» — генерал Г. Норман Шварцкопф
Це також про надійність UI. Якщо ви «сподіваєтеся», що ваш обробник скролу ок, бо він маленький, ви випустите регресію, коли хтось додасть аналітику або запити до DOM у той самий цикл.
Три корпоративні міні‑історії (з яких вчаться)
Міні‑історія 1: Інцидент через неправильне припущення
Команда внутрішньої панелі розгортала новий глобальний хедер з поведінкою «ховай при скролі вниз». Мета була — показувати більше рядків у щільній таблиці. Реалізація виглядала чистою: слухач скролу порівнював window.scrollY з попереднім значенням і переключав display: none для хедера.
Неправильне припущення було тонким: «приховування хедера — це те саме, що його трансляція». У їхній ментальній моделі хедер був лише візуальний. У моделі браузера змiна display впливає на макет, що впливає на висоту скролу, що змінює позицію скролу і викликає нові події скролу.
На сторінках з віртуалізованими таблицями видалення хедера змінювало доступну висоту вьюпорту. Таблиця перераховувала рендеринг рядків. Це тригерило макет. Макет змінив позицію скролу трохи. Слухач скролу побачив рух і знову переключився. Результатом був не нескінченний цикл, але жорстке мерехтіння, яке підняло CPU і зробило сторінку неюзабельною.
Відтворювалося лише на деяких машинах, бо продуктивність визначала, чи осциляція загаситься чи посилиться. Звіт про інцидент завершився найменш гламурним фіксом в історії: тримайте хедер у макеті, ховайте через transform і додайте поріг у пікселі, щоб ігнорувати шум. Ніхто за це не отримав підвищення, але графіки перестали кричати.
Міні‑історія 2: Оптимізація, що відкотилася
Сайт продукту хотів «маслянистої» прокрутки на дешевих Android. Хтось запропонував «GPU‑акселерацію всього» і додав will-change: transform до хедера, хіра, CTA‑рейлу та кількох інших компонентів. Ідея: заздалегідь просунути елементи в шари, уникнути джанку.
Кілька днів це виглядало краще на кількох дев‑пристроях. Потім реальне моніторинг почав показувати більше памʼяті і часті перезавантаження вкладок на мобільних. Просування в шари збільшило навантаження на GPU памʼять, і на деяких пристроях браузер агресивно звільняв ресурси.
Сам хедер був у порядку. Проблема була системною: will-change — це не магія; це підказка, що коштує памʼяті. Занадто багато просунутих шарів може спричинити треш у композиторі або завантаження тайлів. Оптимізація перетворилася на вразливість.
Фікс: використовувати will-change лише на хедері (і лише коли потрібно), спростити тінь і прибрати його звідусіль. Прокрутка знову стала нудною, а це найвища похвала для UI у продакшні.
Міні‑історія 3: Нудна, але правильна практика, що врятувала день
Великий корпоративний веб‑додаток мав правило: будь‑яка глобальна поведінка UI повинна релізитися за фіче‑флагом з кнопкою вимкнення під контролем ops. Це не було сексуально. Інженери іноді кліпали очі. Потім прилетів апдейт браузера.
Оновлення змінило дещо в поведінці прокрутки на підмножині пристроїв. Хедер, що ховався при скролі, почав тремтіти, але тільки коли присутній вбудований сторонній віджет. Віджет інжектував великий fixed‑елемент, що змінював рішення композитингу. Користувачі скаржилися, що хедер «вібрає».
Оскільки фічу було поставлено за флагом, on‑call вимкнув поведінку для уражених UA, поки команда розслідувала. Сайт залишився придатним. Нікому не довелося різати хотфікс опівночі. Наступного дня вони підлатали логіку, щоб уникати переключення під час анімації віджета, і підправили пороги для того браузера.
Урок нудний і постійний: релізуйте UI‑поведінки з можливістю їх вимкнути. Не тому, що ви чекаєте провалу, а тому, що реальність винахідлива.
Практичні завдання: команди, результати та рішення
Ви просили реальні завдання, не просто натяки. Ось перевірки, які я запускаю, коли хедер здається неправильним у продакшні. Це мікс серверної перевірки (чи задеплоєні правильні артефакти?), клієнтського дебагу (чи ми шлемо занадто багато?) і діагностики продуктивності.
Завдання 1: Перевірити, що в деплойнутому CSS є правила sticky + transform
cr0x@server:~$ grep -nE "position:\s*sticky|will-change:\s*transform|translateY" /var/www/app/static/ui.css | head
132:header.site-header { position: sticky;
139: will-change: transform;
151:header.site-header[data-hidden="true"] { transform: translateY(calc(-1 * var(--header-h)));
Що це означає: ключові властивості є в артефакті. Рішення: якщо відсутні — ваша пайплайн деплою, ймовірно, відправив старий бандл або іншу тему; виправте деплой перед дебагом «продуктивності».
Завдання 2: Підтвердити, що висота хедера стала в CSS (без анімованої висоти)
cr0x@server:~$ grep -nE "header\.site-header|height:" -n /var/www/app/static/ui.css | sed -n '120,175p'
132:header.site-header {
145: height: var(--header-h);
151:header.site-header[data-hidden="true"] {
Що це означає: висота задана один раз, не перемикається. Рішення: якщо ви бачите зміну висоти в різних станах — чекайте зсуву макета і переробіть на трансформації.
Завдання 3: Перевірити, що JS‑бандл містить мінімальний обробник скролу
cr0x@server:~$ rg -n "requestAnimationFrame\\(update\\)|passive:\\s*true|data-hidden" /var/www/app/static/app.js | head
8432: requestAnimationFrame(update);
8440: window.addEventListener('scroll', onScroll, { passive: true });
8456: header.dataset.hidden = hidden ? 'true' : 'false';
Що це означає: важливі частини присутні. Рішення: якщо немає passive‑слухачів або rAF, ви ймовірно робите занадто багато роботи в обробнику скролу; виправте це спочатку.
Завдання 4: Перевірити gzip/brotli для JS/CSS (менше ваги — важливо)
cr0x@server:~$ curl -sI -H 'Accept-Encoding: br' http://localhost/static/app.js | grep -iE 'content-encoding|content-length|cache-control'
Content-Encoding: br
Content-Length: 182943
Cache-Control: public, max-age=31536000, immutable
Що це означає: brotli увімкнено; розмір видимий; кешування довге. Рішення: якщо немає компресії або кешування — виправте це перед мікрооптимізацією скрол‑математики.
Завдання 5: Підтвердити правильні MIME‑типи (уникнути дивної поведінки браузера)
cr0x@server:~$ curl -sI http://localhost/static/ui.css | grep -iE 'content-type|cache-control'
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
Що це означає: правильний MIME‑тип і кешування. Рішення: якщо MIME хибний — деякі браузери поводяться інакше; виправте конфіг сервера.
Завдання 6: Виявити випадкові дублікати слухачів скролу в бандлі
cr0x@server:~$ rg -n "addEventListener\\('scroll'" /var/www/app/static/app.js | head -n 20
8440: window.addEventListener('scroll', onScroll, { passive: true });
12110: window.addEventListener('scroll', trackScrollDepth, { passive: true });
17822: document.addEventListener('scroll', legacyScrollHandler);
Що це означає: існує кілька слухачів скролу. Рішення: проведіть аудит. Якщо ви бачите «legacyScrollHandler», ймовірно, у вас конкуруючі поведінки і зайва робота; видаліть або заховайте за флагом.
Завдання 7: Перевірити, що сторінка не примушує макет на скролі (знайти код, що тригерить макет)
cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight|clientHeight" /var/www/app/static/app.js | head
5209: const h = header.offsetHeight;
10902: const rect = el.getBoundingClientRect();
Що це означає: ці виклики можуть тригерити макет, якщо змішані з записами. Рішення: переконайтеся, що ці читання не в обробнику скролу або вони ізольовані перед записами; інакше ви створите примусовий синхронний макет.
Завдання 8: Перевірити Nginx‑логи доступу на предмет частого завантаження активів
cr0x@server:~$ sudo awk '$7 ~ /\/static\/(app\.js|ui\.css)/ {print $7, $9}' /var/log/nginx/access.log | tail -n 8
/static/ui.css 200
/static/app.js 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
Що це означає: багато 200‑ок натякає, що кеш може бути зламаний (мають бути 304 або CDN‑хіти). Рішення: перевірте заголовки кешу і конфіг CDN; повторні завантаження затримують інтер‑активність і можуть погіршувати джанк після навігації.
Завдання 9: Перевірити час відповіді сервера для HTML (повільний TTFB затримує CSS/JS)
cr0x@server:~$ curl -o /dev/null -s -w "ttfb=%{time_starttransfer} total=%{time_total}\n" http://localhost/
ttfb=0.043 total=0.051
Що це означає: швидкий TTFB і загальний час. Рішення: якщо TTFB високий, ваша «проблема з хедером» може бути симптомом пізнього завантаження CSS/JS через повільну доставку HTML; виправте бекенд або кешування.
Завдання 10: Підтвердити, що ви не шлете неврегульовані скрипти третіх сторін
cr0x@server:~$ rg -n "googletagmanager|segment|hotjar|fullstory|datadogRum" /var/www/app/templates/index.html
42:<script>/* datadogRum init */</script>
Що це означає: рантайм третіх сторін присутній. Рішення: якщо скрол гіршає після ініціалізації аналітики, можливо треба відкладати некритичні скрипти, сильно пробувати або ізолювати їх від шляху скролу.
Завдання 11: Перевірити сигнали зсуву макета у логах браузера (типу Lighthouse CI)
cr0x@server:~$ jq '.audits["cumulative-layout-shift"].numericValue' ./lighthouse-report.json
0.19
Що це означає: CLS ненульовий. Рішення: перевірте, чи змінюється висота хедера або верхній паддінг після завантаження, і чи пізні шрифти або банери штовхають контент. Виправте CLS перш ніж шліфувати анімацію ховання.
Завдання 12: Підтвердити, що хедер не вищий на iOS через неправильний safe‑area
cr0x@server:~$ grep -n "safe-area-inset-top" -n /var/www/app/static/ui.css
88:header.site-header { padding-top: env(safe-area-inset-top); height: calc(var(--header-h) + env(safe-area-inset-top)); }
Що це означає: safe area оброблена явно. Рішення: якщо відсутня і у вас є iOS‑користувачі з вирізом, додайте це; обрізана навігація — реальна суть звернень у підтримку.
Завдання 13: Перевірити наявність feature‑flag kill switch для поведінки
cr0x@server:~$ rg -n "HIDE_HEADER_ON_SCROLL|featureFlag.*header" /var/www/app/static/app.js | head
902: if (!window.__FLAGS__?.HIDE_HEADER_ON_SCROLL) return;
Що це означає: поведінку можна вимкнути. Рішення: якщо цього немає, ви обираєте дебаг продакшну шляхом redeploy — це стиль життя, а не інженерне рішення.
Завдання 14: Перевірити логи помилок на JS‑винятки, що залишають хедер прихованим
cr0x@server:~$ sudo journalctl -u nginx -n 50 --no-pager | tail -n 10
Dec 29 10:14:03 web nginx[2213]: 2025/12/29 10:14:03 [warn] 2213#2213: *8930 upstream response is buffered to a temporary file
Що це означає: це серверна інформація і прямо не про JS, але нагадування перевіряти клієнтську телеметрію помилок. Рішення: якщо є клієнтські винятки навколо коду скролу, додайте try/catch і fail open (хедер видимий).
Якщо ви дивуєтеся, чому інженер зберігання каже перевіряти заголовки кешу: тому що затримки та розмір вантажу — це UX, а UX — це продакшн.
План швидкої діагностики
Коли липкий хедер поводиться неправильно, не «налаштовуйте пороги» одразу. Це як витратити попіл дня. Діагностуйте як SRE: ізолюйте, виміряйте, зменшіть змінні.
Перший крок: це зсув макета чи джанк прокрутки?
- Перевірка: чи рухається контент вгору/вниз, коли хедер ховається/показується?
- Тлумачення: якщо так — ви змінюєте макет (height/display/margins) або підвантажуєте активи, що змінюють розмір хедера.
- Дія: зробіть висоту хедера сталою; використайте transform; задайте явні розміри для шрифтів/іконок.
Другий: чи забагато слухачів скролу або дорогих операцій у них?
- Перевірка: шукайте у бандлі
addEventListener('scroll', порахуйте, знайдіть legacy‑обробники. - Тлумачення: кілька обробників часто конкурують і викликають читання/запис у DOM.
- Дія: консолідуйте в один rAF‑батчений обробник; винесіть аналітику за межі шляху скролу.
Третій: чи бореться композитор (paint storms, важкі тіні, прозорі фони)?
- Перевірка: чи стуттер корелюється з важким контентом або лише на слабких пристроях?
- Тлумачення: великі блюри/тіні на рухомих елементах можуть бути дорогими.
- Дія: спростіть тіні; уникайте backdrop‑filter; зменшіть кількість альфа‑шарів; не зловживайте will‑change.
Четвертий: чи це особливість мобільного вьюпорту (Safari динамічна панель)?
- Перевірка: чи відбувається це лише в iOS Safari, особливо при згортанні/розгортанні адресного рядка?
- Тлумачення: виявлення scrollY/top може бути шумною.
- Дія: використайте IntersectionObserver‑сентінел для стану «на верху»; додайте пороги; не покладайтеся на точне scrollY==0.
Пʼятий: чи це баг взаємодії (фокус, клавіатура, цілі для натисків)?
- Перевірка: перейдіть табом у елементи хедера; чи він зникає під час фокусу?
- Тлумачення: ви ховаєте без урахування стану фокусу/взаємодії.
- Дія: тримайте видимим при фокусі; розгляньте короткий таймаут блокування після взаємодії.
Поширені помилки: симптом → причина → виправлення
1) Симптом: хедер мерехтить швидко на трекпадах або сенсорі
Причина: детекція напрямку без мертвої зони; малі дельти постійно перемикають стан.
Виправлення: додайте окремі пороги для приховування/показу; тримайте останній напрям поки не перевищено поріг; оновлюйте стан у rAF.
2) Симптом: контент «стрибає» коли хедер ховається або показується
Причина: ховання змінює макет (height/display/margins) або пізно підвантажуються шрифти/іконки, що змінюють розмір хедера.
Виправлення: тримайте висоту хедера сталою; використовуйте transform; задайте явні висоти; уникайте пізніх ререндерів в хедері.
3) Симптом: хедер перекриває заголовки після кліку по TOC
Причина: відсутня обробка якорних відступів.
Виправлення: використовуйте scroll-margin-top для заголовків або глобальне :target з висотою хедера.
4) Симптом: хедер застряг прихованим після повернення назад
Причина: стан відновлено неправильно; скрипт ініціалізується до наявності хедера; або JS‑виняток перериває оновлення.
Виправлення: виконайте початковий update(); fail open (видимий) при помилках; переконайтеся, що скрипт запускається після DOM або використовує defer.
5) Симптом: прокрутка плавна, доки не завантажиться аналітика, потім починається ривок
Причина: конкуренція головного потоку; аналітика виконує роботу під час скролу або тригерить читання макета.
Виправлення: прибрати аналітику з шляху скролу; семплувати події; використовувати IntersectionObserver для глибини скролу; відкладати некритичні скрипти.
6) Симптом: працює в Chrome на десктопі, але тремтить в iOS Safari
Причина: динамічні тулбари, overscroll, відмінності композитингу.
Виправлення: використовуйте сентінел для стану «вершини»; уникайте точних порівнянь до scrollY==0; тримайте анімації лише через трансформації; поважайте safe area.
7) Симптом: користувачі клавіатури втрачають сфокусований елемент
Причина: хедер ховається під час фокусу; або стан приховування видаляє елементи з макета.
Виправлення: не ховайте коли header.contains(document.activeElement); уникайте display: none як механізму приховування.
8) Симптом: хедер відчувається «повільним» (показується з затримкою при скролі вгору)
Причина: пороги занадто великі; обробник скролу виконується рідко; або важка робота затримує rAF.
Виправлення: тримайте поріг показу меншим за поріг приховування; зменшіть роботу в обробнику; прибирайте дорогі читання DOM.
9) Симптом: тінь хедера перемальовує всю сторінку під час скролу
Причина: важкі тіні/backdrop‑filter на рухомому елементі викликають дорогі пермалювання.
Виправлення: спростіть тінь; уникайте блюр‑фільтрів; перемикайте тінь лише коли треба; розгляньте бордер замість тіні.
10) Симптом: хедер перекриває виріз / статус‑бар на iPhone
Причина: safe area не оброблено.
Виправлення: додайте padding-top: env(safe-area-inset-top) і підкоригуйте висоту хедера відповідно.
Контрольні списки / покроковий план
Покроковий план реалізації (робіть у цьому порядку)
- Спершу випустіть нудний sticky хедер.
position: sticky; top: 0;фіксована висота, без поведінки hide. - Додайте відступи для якорів. Використовуйте
:target { scroll-margin-top: ... }або застосуйте до заголовків. - Додайте стан «elevated» лише. Тінь/бордер після виходу з верху, керований сентінелом або невеликим порогом scrollY.
- Додайте hide/show тільки через transform. Ніяких змін height. Ніяких display‑перемикань.
- Додайте виявлення напрямку з порогами. Поріг приховування більший за поріг показу.
- Захищайте стани взаємодії. Не ховайте під час фокусу або активної вказівної взаємодії, якщо потрібно.
- Повага до reduced motion. Вимикайте переходи (і можливо поведінку) при
prefers-reduced-motion. - Фіче‑флаг. Додайте kill switch; за замовчуванням увімкніть лише після тестування.
- Вимірюйте. Слідкуйте за CLS та INP; перевіряйте продуктивність прокрутки на представницьких пристроях.
Чек‑лист релізу (SRE‑стиль)
- Висота хедера стала у всіх станах (перевірити computed styles).
- Ховання використовує
transform, а не властивості макета. - Лише один слухач скролу для поведінки; він passive і батчиться через rAF.
- Якорі не потрапляють під хедер.
- Safe area оброблена для iOS.
- Повага до reduced motion.
- Fail open: при помилці JS хедер залишається видимим і робочим.
- Фіче‑флаг + kill switch перевірені в продакшні.
- RUM‑дашборди слідкують за регресіями CLS/INP після релізу.
Чек‑лист налаштування (пороги і відчуття)
- Почніть з порога приховування ~10–16px і порога показу ~4–8px.
- Переконайтеся, що «на верху» примусово робить видимим і не підвищеним.
- Віддавайте перевагу «показувати швидко, ховати обережно». Користувачі пробачать хедер, що зʼявляється; вони ненавидять той, що їх блокує.
- Якщо сторінка має нескінченний скрол, будьте дуже консервативні з приховуванням. Користувач уже багато скролить; не додавайте сюрпризів.
Питання й відповіді
1) Чи можна це зробити лише CSS?
Не повністю. CSS може зробити елемент sticky і анімувати стан приховування/показу, коли цей стан виражений. Але «напрям прокрутки» сьогодні не є входом для CSS. Якщо потрібне hide‑on‑down/show‑on‑up — потрібен JS або платформа, якої ще немає.
2) Чи варто використовувати position: fixed замість sticky?
Використовуйте sticky, якщо немає сильної причини інакше. Sticky участує в нормальному потоці, що уникає деяких крайових випадків макета. Fixed теж підходить, але простіше випадково створити перекриття; доведеться самостійно керувати верхніми паддінгами/відступами, щоб уникнути приховування контенту.
3) Чому не перемикати display: none при хованні?
Бо це змінює макет. Це може створити CLS, боротися з anchoring і викликати дорогий рефлоу. Ховання через трансформу тримає макет стабільним і зазвичай краще скролиться.
4) Чи завжди добре використовувати will-change: transform?
Ні. Воно споживає ресурси, заохочуючи просування в шари. Використовуйте помірно, бажано на єдиному елементі, який анімується (хедер). Не розкидайте його по всьому інтерфейсу.
5) А що з backdrop-filter для ефекту матового скла?
Воно може виглядати чудово і працювати жахливо під час руху. Якщо ви мусите його використовувати — тестуйте на слабких пристроях і розгляньте вимикання під час переходів ховання/показу або під капотом перевірки можливостей.
6) Як зупинити хедер від ховання, коли користувач скролить у вкладеному контейнері?
Вирішіть, який контейнер керує поведінкою. Якщо контент скролиться всередині div, використовуйте події і вимірювання цього елемента замість window. Змішування їх — класичне джерело «він ховається випадково».
7) Чому воно поводиться інакше в iOS Safari?
Динамічні тулбари, overscroll і відмінності в вьюпортах. Уникайте логіки, що покладається на точні пікселі на верху. Використовуйте сентінел і додайте пороги, щоб малі осциляції не перемикали стан.
8) Як переконатися, що воно не ховається під час взаємодії з хедером?
Додайте захисти: якщо хедер містить активний елемент, тримайте його видимим. За бажанням заблокуйте видимість на короткий час після pointerdown/touchstart у зоні хедера. Робіть просто і тестуйте з клавіатурою та скрін‑ридерами.
9) Який найбезпечніший режим відмови?
Хедер залишається видимим. Якщо JS не завантажився, впав або заблокований, користувач має мати навігацію. Ось чому CSS‑перший підхід важливий.
10) Як вимірювати успіх окрім «відчуття плавності»?
Слідкуйте за CLS і INP, плюс за сигналами залученості (напр., використання навігації — акуратно, без логування на кожен скрол). Якщо CLS росте після релізу, вважайте, що хедер міг цьому сприяти, поки не доведено інше.
Висновок: наступні кроки, які можна виконати
Будуйте липкий хедер як надійну службу: почніть зі стабільної бази, додавайте контрольовану поведінку за невеликою машиною станів і тримайте кнопку відключення під рукою. CSS дає стабільність. Мінімальний JS дає те, чого CSS не може: напрям.
Наступні кроки:
- Аудитуйте поточний хедер: якщо він змінює висоту або display під час скролу — виправте це спершу.
- Додайте
:targetscroll‑margin (або відступи для заголовків), щоб уникнути перекривання якорів. - Реалізуйте rAF‑батчений, passive обробник напрямку скролу з порогами; тримайте його менше 50 рядків.
- Додайте сентінел для elevation, якщо мобільні курйози вам дошкуляють.
- Релізуйте за фіче‑флагом, слідкуйте за CLS/INP і будьте готові вимкнути без redeploy.
Коли все працює — ніхто цього не помічає. Ось у чому суть. Хедер — інструмент, а не виставковий перформанс.