Права панель змісту для документації: липка, scroll-margin і підсвічування активного розділу
Сторінка документації довга. Хедер липкий. Хтось клікає пункт у змісті і потрапляє під заголовок, напівприхований навігаційною панеллю.
Потім підсвічування активного розділу неправильно відображає місце. Це не «дрібна помилка UI»; це тертя, яке змушує людей покидати сторінку.
Ось як зробити праву панель змісту, яка поводиться як досвідчена система: липка, але не набридлива; переходи за якорями, що потрапляють точно,
і підсвічування активного розділу, яке не навантажує головний потік і не дезорієнтує читачів.
Більшість проблем зі змістом — самопороджені: погані ID заголовків, відсутні зсуви для прокрутки та хиткі слухачі прокрутки.
Зміст
Зміст праворуч побудований із заголовків на цій сторінці. Якщо він здається нудно надійним — чудово. Це і є мета.
- Вимоги, які насправді важливі
- Факти та контекст: чому змісти стали проблемними
- Макет: липкий правий зміст, що не конфліктує зі сторінкою
- Якорі та прокрутка: scroll-margin і обробка зсувів
- Підсвічування активного розділу: правильний IntersectionObserver
- Продуктивність і надійність: уникайте самозроблених аварій
- Швидкий план діагностики
- Поширені помилки (симптом → причина → виправлення)
- Три корпоративні історії з практики
- Практичні завдання (команди, виходи, рішення)
- Контрольні списки / покроковий план
- FAQ
- Висновок: наступні кроки, які можна відправити в реліз
Вимоги, які насправді важливі
Зміст — це навігаційна система. Ставтеся до неї як до такої. Це означає, що він має вимоги, які виходять за межі «гарно виглядає на моєму екрані».
Насправді ви оптимізуєте для: коректності, відчутної швидкодії, доступності та низького рівня підтримки.
Вимоги до коректності
- Переходи за якорями мають приземлятися на правильний візуальний рядок, а не під липкі хедери. Використовуйте
scroll-margin-topна заголовках; припиніть ганятися за зсувами через JS. - Підсвічування активного розділу має відповідати тому, що бачать користувачі. «Найближчий заголовок до верху» складніший, ніж здається, на довгих сторінках зі змішаними H2/H3 і блоками коду.
- Глибокі посилання переживуть рефакторинг. ID заголовків мають бути стабільними. Якщо генератор змінює ID через пунктуацію або емодзі — ви зламаєте посилання в задачах, чатах і м’язовій пам’яті користувачів.
Вимоги до продуктивності
- Жодних гарячих циклів на scroll-подіях. Зміст не повинен робити сторінку обігрівачем. Використовуйте
IntersectionObserverі тротлінг для залишкових подій. - Працює на великих сторінках. Сторінки документації можуть налічувати тисячі рядків. Обробка 200 заголовків не має ставати кризою.
- Жодного layout thrash. Уникайте багаторазових викликів
getBoundingClientRect()в обробниках прокрутки.
Вимоги UX і доступності
- Клавіатурна навігація працює (Tab до змісту, Enter для активації). Не створюйте фальшиве меню, що затягує фокус.
- Видимі стани фокусу. Якщо зміст підсвічує «активне», але приховує «фокус», користувачі з клавіатурою постраждають.
- Чесна адаптивна поведінка. На мобільних праворуч зміст перестає існувати. Він стає верхньою панеллю або сховищем; не змушуйте двоколонний макет, що задавлює контент.
Жарт №1: «scrollspy» — як стажер з біноклем: вражає, поки не дізнаєшся, що він доповідає з п’ятисекундною затримкою.
Факти та контекст: чому змісти стали проблемними
Змісти видаються простими, бо вони з нами давно. Але веб постійно змінювався під ними: липкі хедери, SPA, динамічний контент і нові примітиви верстки, яких 15 років тому не було.
Нижче конкретні факти, які пояснюють сучасні гострі кути.
position: sticky стандартизували після років різної поведінки; «sticky у прокручуваному контейнері» й досі підводить команди, коли предок має overflow.IntersectionObserver з’явився саме щоб уникнути обробників прокрутки, що змушували синхронні розрахунки макета і витрачали батарею на мобільних.scroll-margin-top походить із ери CSS Scroll Snap, але корисний і без snap’ів.Генерал Гордон Р. Салліван
Нічого з цього не екзотичне. Трик — вибрати примітиви, які стійкі до змін: CSS для зсувів, API спостереження для відстеження активності і чиста розмітка для доступності.
Макет: липкий правий зміст, що не конфліктує зі сторінкою
Правий зміст найкраще працює коли це сусідня колонка в grid або flex, а не абсолютно спозиційований оверлей.
Оверлейні змісти — причина «чому я не можу виділити текст» і загадкових проблем з кліками.
Мінімально життєздатний макет
Використовуйте grid з колонками для контенту та змісту. Дайте змісту position: sticky і розумний top, що враховує ваш липкий хедер.
Потім зробіть внутрішню прокрутку змісту через max-height і overflow: auto, щоб він не виходив за межі вікна.
Режим відмови: sticky не прилипає. Зазвичай це через предка з overflow: hidden/auto або відсутній контекст висоти.
Sticky вибагливий, не зламаний.
Правила контейнера, що вбережуть вас від проблем
- Не загортайте всю сторінку в прокручуваний контейнер без реальної причини. Дозвольте
document.scrollingElementбути браузерним дефолтом. - Якщо прокручувальний контейнер необхідний, нехай підсвічування змісту спостерігає саме цей контейнер (через
rootу observer), інакше «активний» стан буде відставати або ламатися. - Тримайте ширину змісту відносно фіксованою, але не в жорстких пікселях повсюдно. Ширина через clamp або CSS‑змінна спрощує майбутні зміни.
Зробіть адаптивно без героїзму
На менших екранах «права панель» перестає існувати. Є два адекватні варіанти:
- Перенести зміст над статтею (просто, надійно, без JS).
- Застосувати висувне сховище (складніше; тепер ви відповідаєте за фокус і ARIA‑стани).
Якщо обираєте сховище — робіть компонент з тестами. Інакше він поламається при наступній «невеликій» зміні відступів.
Якорі та прокрутка: scroll-margin і обробка зсувів
Ось найпоширеніша помилка змісту: клік по посиланню, браузер прокручує, липкий хедер накриває заголовок.
Люди тоді трохи піднімають сторінку, що змінює «активний» розділ, і підсвічування скаче. Це не дрібний глюк; це петля.
Використовуйте scroll-margin-top на заголовках (не JS‑зсуви)
Додайте це до самих заголовків:
h2, h3 { scroll-margin-top: calc(var(--headerHeight) + 16px); }
Працює для навігації по хешах, програмних element.scrollIntoView() і користувацьких стрибків.
Також масштабується між сторінками без потреби пам’ятати «додати 72px до зсуву» в шести різних функціях.
Стабілізуйте ID заголовків
Посилання у змісті надійні стільки, скільки надійні ваші ID. У продакшн‑доках люди вставляють глибокі посилання в задачі.
Потім ви перейменовуєте «Cache & Consistency» на «Cache consistency», генератор змінює слаг, і раптом служба підтримки займається археологією.
Робіть так:
- Надавайте перевагу явним ID, коли можливо (автор пише їх, генератор їх зберігає).
- Використовуйте детерміністичну функцію слагування (малі літери, дефіси, видалення пунктуації) і зафіксуйте це як контракт інтерфейсу.
- Коли потрібно змінювати ID, додайте редиректи для хешів, якщо платформа дозволяє (деякі статичні маршрутизатори можуть відобразити старі хеші).
Визначте поведінку фокусу
Навігація по хешах прокручує, але може не перемістити клавіатурний фокус на заголовок. Для доступності часто добре сфокусувати заголовок (або прихований анкер),
але треба уникнути зсуву позиції прокрутки, викликаючи focus() без preventScroll.
Прагматичний підхід:
- При клікові в змісті дозвольте браузеру виконати прокрутку за хешем.
- Потім викличте
heading.focus({ preventScroll: true })на фокусованому заголовку (додайтеtabindex="-1").
Якщо пропустите це, користувачі з клавіатурою та допоміжними технологіями отримають гірший досвід. Якщо зробите неправильно — отримаєте подвійні смикання прокрутки. Обирайте свідомо і реалізуйте уважно.
Підсвічування активного розділу: IntersectionObserver зроблений правильно
Підсвічування активного розділу — це не іграшка. Воно допомагає читачам орієнтуватися на довгих сторінках.
Коли воно неправильне — люди відчувають це негайно. Можуть не написати баг, але припинять довіряти вашим матеріалам.
Модель: «активний» — це найближчий заголовок над пороговою лінією
Наївна модель — «заголовок, що видимий зараз». Вона ламається, коли видно два заголовки або коли заголовок видимий, але ви глибоко в секції.
Краще визначення:
- Встановіть порогову лінію зверху (звичайно безпосередньо під липким хедером).
- Активним є останній заголовок, чий верх розташований над цією лінією.
IntersectionObserver може апроксимувати це, спостерігаючи заголовки і відстежуючи, які з них перетнули «верхню смугу».
Налаштуйте це через rootMargin, щоб «вікно» обсерверу починалося нижче липкого хедера.
Робоча стратегія обсерверу
Використовуйте обсервер для заголовків (H2 і за бажанням H3). Тримайте невелику мапу станів видимості.
У зворотному виклику обчислюйте найкращий активний заголовок на основі:
- Порядку заголовків у документі
- Чи знаходиться bounding box заголовка в межах верхньої смуги
- Fallback: перший заголовок для початку сторінки або останній при досягненні низу
Не робіть: при кожній прокрутці перелічувати всі заголовки і викликати getBoundingClientRect(). Такий підхід викликає layout thrash і непередбачувані кадри на довгих сторінках.
Обробка «стрибка по хешу» і кнопки назад
Коли сторінка завантажується з хешем, браузер прокручує раніше, ніж ваш JS може підготуватися. Підсвічування має залишатися коректним.
Це означає:
- При ініціалізації встановіть активний елемент на основі
location.hash, якщо він відповідає ID заголовка. - Після того як шрифти/зображення устаканяться, перевірте ще раз (одноразово). Один
requestAnimationFrameабоsetTimeoutпісля load зазвичай достатньо.
Тримайте активний елемент видимим всередині змісту
Якщо сам зміст прокручується, переконайтеся, що активний пункт залишається в полі зору (делікатна автопрокрутка).
Робіть це чемно: прокручуйте зміст тільки коли активний елемент за межами видимості, і використовуйте scrollIntoView({ block: "nearest" }).
Жарт №2: Якщо підсвічування змісту відстає — вітаю, ви винайшли сторінку статусу для прокрутки.
Продуктивність і надійність: уникайте самозаподіяних аварій
Зміст у документації може впливати на реальні бізнес‑метрики. Не через витрати CPU, а через довіру.
Коли навігація ламається, люди вважають контент неякісним. А коли ваша документація живе в UI продукту, поганий зміст може погіршити загальний перформанс додатка.
Де змісти найчастіше вмирають
- Динамічне інжектування контенту: заголовки з’являються після початкового рендеру (MDX гідратація, клієнтські запити). Ваш зміст має перевіряти і повторно підключати спостерігачі.
- Зсуви макета: зображення без розмірів, пізно завантажувані шрифти, акордеони. Логіка «активного заголовка» має толерувати рухи.
- Вкладені прокручувані контейнери: основний контент прокручується всередині div, а не вікна. Обсервери потребують вказівки
root, аscroll-margin-topможе не відповідати видимій поведінці хедера. - Забагато обсерверів: створювати по одному обсерверу на заголовок неефективно. Хочете один екземпляр, що спостерігає багато цілей.
Пастки доступності
Зміст — по суті набір внутрішньосторінкових посилань. Ставтеся до нього як до <nav aria-label="On this page">.
Тримайте розмітку семантичною і простою. Перепроектування — де ARIA найчастіше використовується неправильно.
- Використовуйте
aria-current="true"(абоaria-current="location") на активному посиланню. - Тримайте текст посилань коротким і однаковим із заголовками, коли можливо (екрани читачів не повинні здогадуватися).
- Переконайтеся, що стилі фокусу видимі на вашому фоні.
Реалії SPA і платформ документації
Якщо у вас маршрутизація на клієнті, потрібно перебудовувати зміст при зміні маршруту і перепідключати обсервери. Це означає:
- Слухайте події маршруту (фреймворк‑специфічно) і повторно запускайте налаштування змісту.
- Відключайте обсервери під час teardown, щоб уникнути витоків пам’яті.
- Не припускайте, що заголовки існують одразу після навігації; чекайте на завершення рендеру.
Погляд SRE: якщо UI‑функція потребує складної «послідовності ініціалізації», вона зламається при часткових релізах, A/B‑тестах або експериментах з контентом.
Зробіть зміст толерантним до відсутності заголовків і затриманого контенту.
Швидкий план діагностики
Коли зміст ламається в продакшні, у вас немає часу на філософські диспути про API прокрутки.
Потрібно швидко знайти вузьке місце: CSS‑макет, зсуви якорів, логіка обсерверу або життєвий цикл платформи.
Перше: чи працює sticky?
- Відкрийте сторінку, прокрутіть, підтвердіть, що зміст залишається прикріпленим під хедером.
- Якщо ні: перевірте предків на
overflowі трансформи. Sticky тихо ламається, коли контекст макета неправильний.
Друге: чи правильно приземляються переходи за якорями?
- Клікніть середній пункт змісту. Якщо заголовок прихований під хедером — у вас відсутній
scroll-margin-top(або застосовано не до того елемента). - Якщо приземляється правильно, але потім «стрибає» — ймовірно, у вас конкуруючий JS викликає
scrollIntoView()або фокус безpreventScroll.
Третє: чи коректне й стабільне підсвічування?
- Пройдіть повільно кордон між секціями. Якщо підсвічування мерехтить — ваші thresholds або rootMargin налаштовані неправильно.
- Якщо воно ніколи не оновлюється — обсервер може спостерігати не той root (вікно vs контейнер прокрутки) або заголовки не підключені після гідратації.
Четверте: чи ламається лише на деяких сторінках?
- Порівняйте сторінки з різним контентом: багато блоків коду, зображень, вкладених заголовків або згортаних панелей.
- Шукайте колізії ID заголовків (дублікати) і некоректні ID (пробіли, пунктуація) якщо генератор неякісний.
П’яте: чи ламається після навігації в SPA?
- Якщо на початку працює, а при переході по маршрутах — ні, ви не ініціалізуєте повторно або не відключаєте старі обсервери.
- Якщо працює після повного оновлення сторінки, але не при клієнтській навігації — ваш код запускається до рендеру заголовків.
Поширені помилки (симптом → причина → виправлення)
Симптом: клік по пункту змісту приводить «занадто високо» або «занадто низько»
Причина: відсутній scroll-margin-top, або застосовано до неправильного елемента (наприклад контейнера замість заголовка). Іноді висота липкого хедера змінюється на різних точках перелому.
Виправлення: застосуйте scroll-margin-top безпосередньо до h2/h3 (або до анкера) з використанням CSS‑змінної для висоти хедера по брейкпойнтах.
Симптом: підсвічування помилкове біля низу сторінки
Причина: останній заголовок ніколи не «перетинає» смугу обсерверу, особливо якщо rootMargin/threshold налаштовані під середню частину сторінки.
Виправлення: додайте нижній сентінел або спеціальну обробку «біля низу» перевіркою scrollPosition vs scrollHeight і форсуйте останній заголовок як активний.
Симптом: підсвічування швидко мерехтить між двома заголовками
Причина: пороги занадто чутливі; заголовки розташовані близько; зсуви макета (зображення) викликають невеликі зміни прокрутки. Ще джерело — мікс H2 і H3 без узгодженої політики активності.
Виправлення: використайте єдину стратегію порогу (верхня смуга), зменште кількість спостережуваних цілей (відслідковуйте лише H2 для активності, H3 — як навігаційні), і дебаунсьте оновлення в animation frame.
Симптом: липкий зміст перестає прилипатиме на деяких сторінках
Причина: предок має overflow: hidden/auto або transform, що створює новий контекст, який змінює поведінку sticky.
Виправлення: приберіть overflow/transform у предка або винесіть липкий елемент за межі цього контексту. Якщо не можна — використайте іншу стратегію (наприклад fixed + padding), але будьте готові до більшої кількості роботи.
Симптом: пункти змісту не відповідають заголовкам після оновлення контенту
Причина: зміст згенеровано на етапі збірки, але контент інжектується в рантаймі; або заголовки змінюються клієнтським рендером після ініціалізації змісту.
Виправлення: генеруйте зміст у рантаймі після рендеру або обережно спостерігайте за мутаціями DOM і перескановуйте заголовки при значущих змінах.
Симптом: клік по змісту викликає «подвійну прокрутку»
Причина: одночасно спрацьовують дефолтна навігація по хешу і JS‑обробник, що викликає scrollIntoView(). Або ви фокусуєте заголовок без preventScroll.
Виправлення: оберіть один метод навігації. Якщо використовуєте хеші — дозвольте браузеру прокрутити і додавайте фокус з preventScroll.
Симптом: сторінка «гальмує» під час прокрутки
Причина: обробник scroll читає і записує layout повторно. Або занадто багато DOM‑оновлень (перемикання класів) на кожен тик прокрутки.
Виправлення: перейдіть на IntersectionObserver, оновлюйте DOM лише коли активний заголовок змінився, і групуйте зміни класів.
Симптом: дубльовані заголовки ламають глибокі посилання
Причина: ваш генератор слагів видає однакові ID для однакових заголовків, тому посилання вказує на перше входження.
Виправлення: розрізняйте ID, додаючи детерміністичні суфікси (наприклад -2, -3).
Три корпоративні історії з практики
Інцидент: неправильне припущення про «контейнер прокрутки»
Компанія випустила портал документації вбудований у UI продукту. Він виглядав сучасно: фіксований верхній нав, ліва колонка і чистий правий зміст.
Також там був кастомний контейнер прокрутки, бо продуктова команда хотіла, щоб фрейм додатка відчувався «нативно».
У staging зміст працював ідеально. У продакшні — нісенітниця: іноді активний елемент ніколи не змінювався; іноді стрибав на два секції одразу.
Support класифікував це як «спорадично», бо залежало від висоти вікна — баг, якому подобається неправильно діагностуватися.
Неправильне припущення було тонким: реалізація змісту використовувала IntersectionObserver з дефолтним root (viewport).
Але фактична прокрутка відбувалася всередині div.app-scroll. З точки зору браузера заголовки не рухалися відносно viewport так, як команда очікувала.
Виправлення було нудне та негайне: вказати root обсерверу на контейнер прокрутки і використати rootMargin, що враховує липкий хедер всередині того ж контейнера.
Також прибрали зайвий вкладений overflow‑обгортку, яка блокувала sticky-позиціонування самого змісту.
Висновок: якщо ви робите кастомну прокрутку — ви відповідаєте за всі побічні ефекти. «Контейнер прокрутки» — не деталь; це головний герой.
Оптимізація, що повернулася бумерангом: «попередньо обчислимо все під час завантаження»
Інша організація мала docs із важким технічним контентом — багато блоків коду й API‑довідок.
Вони помітили, що підсвічування змісту гальмує на слабких ноутбуках, тому хтось «оптимізував» це, кешуючи Y‑координати всіх заголовків при завантаженні сторінки.
Зміни добре тестувалися на невеликому наборі сторінок, потім розгорнули по всьому корпусу.
Через тиждень почали надходити скарги: клік по посиланню приводить правильно, але підсвічування зміщене на одну секцію. Гірше — на сторінках з діаграмами.
Ось що сталося: зображення і шрифти завантажилися пізніше, макет зсунувся, заголовки перемістилися, а закешовані Y‑позиції — ні.
Логіка залишалася швидкою, так. Вона також була впевнено неправильною — мабуть, гірший тип помилки.
Вони відкотили кеш і перейшли на IntersectionObserver. Там, де потрібні були зсуви, позиції обчислювалися лениво, вчасно, і ніколи не припускали стабільність макета до завершення завантаження.
Висновок: оптимізація, що ігнорує зсуви макета, — не оптимізація, а подорож у минулий DOM, якого вже немає.
Нудна, але правильна практика, що врятувала: стабільні ID і шар сумісності
Команда мігрувала від одного Markdown‑рендерера до іншого заради кращого підсвічування синтаксису.
Новий рендерер — нові правила слагування. Старі глибокі посилання з задач і рукописів почали падати.
Не критична аварія, але поторопила тих, хто вже нервував: інженерів на чергуванні, що шукали процедури.
Команда, яка уникла болю, зробила дві нудні речі раніше:
по‑перше, вимагали явні ID для топ‑рівневих заголовків; по‑друге, тримали невелику карту сумісності для старих хешів, що редіректить на нові ID.
Під час міграції вони запустили автоматичну перевірку по корпусу: витягли всі ID до і після, зробили diff і згенерували мапи для типових розривів.
Де не могли надійно змеппити — відмовляли в зміні, доки автори не додали явні ID.
Результат був нудний. Ніяких помилково зламаних посилань. Support не помітив. Чергування не помітили. Оце і є перемога.
Висновок: стабільні анкори — це операційні дані. Ставтеся до них серйозно, як до сумісності API.
Практичні завдання (команди, виходи, рішення)
Нижче завдання, які можна виконати сьогодні на типовому Linux‑робочому місці або в CI‑ранері, щоб діагностувати і запобігти регресіям змісту.
Кожне завдання включає: команду, що означає її вихід, і рішення.
Я використовую директорію збірки dist/ і джерело docs/. Налаштуйте імена; збережіть сенс.
Завдання 1: Підтвердити, що заголовки мають ID (базова гігієна)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23][^>]*>' dist | head
dist/guide.html:118:<h2 id="requirements-that-actually-matter">Requirements that actually matter</h2>
dist/guide.html:176:<h2 id="facts-and-context">Facts and context: why TOCs got weird</h2>
dist/guide.html:265:<h3 id="the-minimum-viable-layout">The minimum viable layout</h3>
Що це означає: ви бачите заголовки з id="...". Якщо ID відсутні, посилання в змісті будуть крихкі або неможливі.
Рішення: якщо ID відсутні — налаштуйте рендерер/піплайн збірки на емісію детерміністичних ID або вимагайте явних ID при авторингу.
Завдання 2: Знайти заголовки без ID (список помилок)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23](?![^>]*\sid=)[^>]*>' dist | head
dist/faq.html:44:<h2>FAQ</h2>
dist/intro.html:90:<h3 class="note">A subtle caveat</h3>
Що це означає: ці заголовки позбавлені ID.
Рішення: або (a) генеруйте ID на етапі збірки, або (b) виключайте такі заголовки зі генератора змісту, щоб уникнути мертвих посилань.
Завдання 3: Знайти дубльовані ID (тиха корупція посилань)
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import Counter
ids = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids += re.findall(r'\bid="([^"]+)"', s)
c = Counter(ids)
dups = [k for k,v in c.items() if v > 1]
print("duplicate ids:", len(dups))
print("\n".join(dups[:20]))
PY
duplicate ids: 2
faq
overview
Що це означає: принаймні два ID повторюються по сторінках (або в межах сторінки). У межах сторінки — реальна проблема для навігації по хешах.
Рішення: забезпечте унікальність ID на сторінці. Якщо дублікати лише між сторінками — це прийнятно. Якщо в межах однієї сторінки — змініть слаггер, додаючи суфікси.
Завдання 4: Перевірити наявність scroll-margin-top у згенерованому CSS
cr0x@server:~$ rg -n --glob 'dist/**/*.css' 'scroll-margin-top' dist | head
dist/assets/site.css:211:h2{scroll-margin-top:calc(var(--headerHeight) + 16px)}
dist/assets/site.css:212:h3{scroll-margin-top:calc(var(--headerHeight) + 16px)}
Що це означає: у збірці є правило для зсуву.
Рішення: якщо відсутнє — додайте його в глобальний стиль для контенту документації, а не як одноразову правку на одній сторінці.
Завдання 5: Перевірити, чи не перемагає sticky overflow у предків
cr0x@server:~$ rg -n --glob 'dist/**/*.html' 'overflow:\s*(auto|hidden|scroll)' dist | head
dist/assets/site.css:88:.shell{overflow:hidden}
dist/assets/site.css:132:.content-wrap{overflow:auto}
Що це означає: у вас є правила overflow, що можуть створювати контексти, які впливають на sticky.
Рішення: аудитуйте обгортки макета. Якщо зміст всередині елементу з overflow відмінним від visible, поведінка sticky може змінитися. Перемістіть липкий елемент або приберіть overflow.
Завдання 6: Підтвердити, що посилання змісту відповідають реальним ID
cr0x@server:~$ python3 - <<'PY'
import bs4, glob
from bs4 import BeautifulSoup
for fn in ["dist/guide.html"]:
s = open(fn, "r", encoding="utf-8").read()
soup = BeautifulSoup(s, "html.parser")
ids = {t.get("id") for t in soup.select("[id]")}
bad = []
for a in soup.select("aside#toc a[href^='#']"):
h = a.get("href")[1:]
if h and h not in ids:
bad.append(h)
print(fn, "bad toc hrefs:", bad[:20])
PY
dist/guide.html bad toc hrefs: []
Що це означає: анкори у змісті резольвляться до елементів на сторінці.
Рішення: якщо є «погані» href — ваш генератор змісту не синхронізований з рендерером, або заголовки додаються/видаляються після генерації.
Завдання 7: Знайти «хеш‑посилання», що ні на що не вказують по сайту
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import defaultdict
by_page_ids = {}
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids = set(re.findall(r'\bid="([^"]+)"', s))
by_page_ids[fn] = ids
broken = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
for href in re.findall(r'href="#([^"]+)"', s):
if href not in by_page_ids[fn]:
broken.append((fn, href))
print("broken in-page hashes:", len(broken))
for x in broken[:10]:
print(x[0], "#"+x[1])
PY
broken in-page hashes: 1
dist/faq.html #top
Що це означає: принаймні одне in‑page хеш‑посилання не відповідає елементу на сторінці.
Рішення: додайте відсутній ID (наприклад id="top") або видаліть посилання.
Завдання 8: Замірити, скільки заголовків ви спостерігаєте (перевірка масштабу)
cr0x@server:~$ python3 - <<'PY'
import glob, re
fn = "dist/guide.html"
s = open(fn, "r", encoding="utf-8").read()
h2 = len(re.findall(r'<h2\b', s))
h3 = len(re.findall(r'<h3\b', s))
print("h2:", h2, "h3:", h3, "total:", h2+h3)
PY
h2: 12 h3: 27 total: 39
Що це означає: на цій сторінці 39 заголовків. Спостерігати 39 елементів — нормально. Спостерігати 400 може бути також прийнятно, але потрібно діяти обережно.
Рішення: якщо заголовків дуже багато, розгляньте спостереження лише H2 для активності, а H3 — лише як навігаційні, або реалізуйте розумніший вибір цілей.
Завдання 9: Переконатися, що ви не відправляєте обробник прокрутки, який працює кожен тик
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'addEventListener\(\s*["'\'']scroll["'\'']' dist | head
dist/assets/toc.js:14:window.addEventListener('scroll', onScroll)
Що це означає: ви підключаєте слухач прокрутки.
Рішення: перевірте, що він робить. Якщо читає макет і оновлює DOM на кожну подію прокрутки — замініть на IntersectionObserver або тротлінгуйте до animation frames і оновлюйте лише при зміні активного елемента.
Завдання 10: Переконатися, що використовується IntersectionObserver (і не по одному на заголовок)
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'new\s+IntersectionObserver' dist
dist/assets/toc.js:38:const io = new IntersectionObserver(onIntersect, { root: null, rootMargin: '-72px 0px -70% 0px', threshold: [0, 1] })
Що це означає: у збірці є екземпляр IntersectionObserver.
Рішення: переконайтеся, що це один обсервер, що переиспользується для багатьох заголовків. Якщо він створюється в циклі — виправте це.
Завдання 11: Швидке локальне тестування продуктивності (Lighthouse/PSI‑подібне)
cr0x@server:~$ node -e "console.log('Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.');"
Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.
Що це означає: так, це плейсхолдер — але рішення реальні: вимірюйте продуктивність прокрутки за допомогою інструментів, а не «на око».
Рішення: якщо при прокручуванні з’являються довгі таски — перевірте код змісту в першу чергу. Це поширена причина, бо він виконується під час прокрутки за визначенням.
Завдання 12: Переконатися, що заголовки в одному основному лендмарку для скрін‑рідерів
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<main\b' dist | head
dist/guide.html:52:<main>
dist/faq.html:18:<main>
Що це означає: сторінка використовує <main> як лендмарк, що покращує навігацію для допоміжних технологій.
Рішення: якщо відсутній — додайте. Потім переконайтеся, що ваш зміст — це <nav aria-label="On this page">, а не випадковий div.
Завдання 13: Переконатися, що активний пункт змісту встановлює aria-current
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'aria-current' dist
dist/assets/toc.js:97:link.setAttribute('aria-current', isActive ? 'location' : 'false')
Що це означає: ваш JS перемикає aria-current.
Рішення: якщо немає — додайте. Якщо ставить недійсні значення — виправте на location або видаляйте атрибут, коли неактивний.
Завдання 14: Виявити зміни тексту заголовків, що можуть ламати стабільні ID
cr0x@server:~$ git diff --word-diff -- docs/guide.md | head -n 40
diff --git a/docs/guide.md b/docs/guide.md
--- a/docs/guide.md
+++ b/docs/guide.md
@@
-## Active section highlighting: IntersectionObserver done right
+## Active section highlighting with IntersectionObserver
Що це означає: заголовок змінився. Якщо ваші ID походять від тексту заголовка, ID, ймовірно, змінився теж.
Рішення: або тримайте стабільні явні ID, або керуйте зміною (редиректи/мапи). Не дозволяйте цьому змінюватися непомітно.
Завдання 15: Підтвердити, що SPA‑маршрути реініціалізують зміст (димовий тест через логи)
cr0x@server:~$ rg -n --glob 'src/**/*.ts' 'initToc\(|setupToc\(' src | head
src/toc/init.ts:12:export function initToc(){ ... }
src/router.ts:48:router.on('routeChangeComplete', () => initToc())
Що це означає: ініціалізація змісту викликається після зміни маршруту.
Рішення: якщо відсутня — додайте. Якщо є, але з багом — переконайтеся, що відключаєте попередні обсервери і враховуєте затриманий рендер.
Контрольні списки / покроковий план
Покроково: відправити зміст, який поводиться
- Визначте політику заголовків. Вирішіть, які рівні відображати (лише H2 або H2+H3). Узгодьте стабільні ID.
- Реалізуйте зсув прокрутки в CSS. Додайте
scroll-margin-topна заголовки з використанням змінної висоти хедера. - Побудуйте семантичну розмітку. Зміст — це
<nav aria-label="On this page">зі списком посилань. - Зробіть зміст липким. Використайте grid; застосуйте
position: sticky, top‑зсув, max‑height і внутрішню прокрутку. - Підсвічування активного з IntersectionObserver. Один екземпляр обсерверу, багато цілей, rootMargin під хедер.
- Встановіть aria-current. Використовуйте
aria-current="location"на активному посиланню; видаляйте при неактивності. - Обробка хеша при завантаженні. При ініціалізації, якщо
location.hashвідповідає заголовку, встановіть його активним одразу. - Обробка SPA навігації. Перескановуйте заголовки при зміні маршруту і відключайте старі обсервери.
- Протестуйте випадки зсуву макета. Сторінки з зображеннями, блоками коду і згортаними панелями. Переконайтеся у відсутності мерехтінь.
- Гарантії в CI. Перевірка на відсутні ID, дубльовані ID і зламані in‑page хеш‑посилання.
Чекліст: жорстке закріплення в продакшн
- Зміст працює, коли JS відключений (посилання все ще прокручують через хеш).
- Висота липкого хедера послідовна (або CSS‑змінна змінюється по брейкпойнтах).
- ID стабільні й детерміністичні; дублікати розрізняються.
- Підсвічування не мерехтить при повільній прокрутці.
- Зміст не викликає довгих тасків під час прокрутки.
- Клавіатурна навігація і стани фокусу видимі.
Чекліст: чого не робити
- Не підключайте обробник прокрутки, що перераховує позиції всіх заголовків кожен тик.
- Не зберігайте кешовані зсуви, якщо не відстежуєте зсуви макета (і швидше за все ви цього не зробите правильно).
- Не робіть «drawer TOC» на мобільних, якщо ви не готові відповідати за ARIA, фокус‑трап і закриття по Escape.
- Не допускайте, щоб ID заголовків змінювалися легковажно. Це ламає сумісність; ставтеся до цього як до зламів API.
FAQ
1) Генерувати зміст на етапі збірки чи в рантаймі?
Збірка простіша і швидша, але лише якщо відрендерені заголовки стабільні і не інжектяться після завантаження.
Якщо у вас MDX‑гідратація або клієнтський контент — робіть генерацію в рантаймі (або збірка + рантайм‑узгодження).
2) Чи можна покладатися на scroll-margin-top?
Так для сучасних браузерів. Якщо ви підтримуєте дуже старі движки — потрібні fallback’и, але більшість порталів документації можуть вимагати сучасні рушії.
Більший ризик — забути застосувати правило до фактичного анкерного елемента.
3) Чому не просто використовувати обробник scroll?
Можна, але ви заново винайдете половину IntersectionObserver погано: тротлінг, читання макета і крайні випадки біля низу сторінки.
Обробники прокрутки також часто регресують, бо «працює» доти, поки не додати ще один тип контенту.
4) Моя підсвітка зміщена на одну секцію. Яка типова причина?
RootMargin не враховує висоту липкого хедера, або ваша дефініція «активного» — «будь‑який видимий заголовок», а не «останній над порогом».
Налаштуйте rootMargin і виберіть детерміністичне правило.
5) Чи виділяти H3 теж?
Тільки якщо це корисно читачам. На дуже щільних сторінках підсвічування H3 мерехтить, бо вони розташовані близько.
Компроміс: активний — поточний H2; всередині нього підсвічуйте найближчий H3, якщо він комфортно віддалений.
6) Як уникнути ламання глибоких посилань при зміні заголовків?
Використовуйте явні ID для важливих секцій (рукбуки, API, трблшутинг). Якщо не можете — тримайте шар сумісності, що мапить старі хеші на нові ID.
Інакше прийміть факт ломки і повідомте про це користувачів.
7) А як бути зі сторінками, що мають згортання або вкладки?
Згортання ускладнюють «активність», бо видимість контенту змінюється. Безпечний шлях: включайте лише ті заголовки, що завжди в потоці.
Якщо включаєте заголовки всередині згортань — зміст має розуміти стан відкриття/закриття і оновлювати обсервери відповідно.
8) Чому sticky ламається лише на деяких сторінках?
Бо деякі сторінки мають обгортку з overflow або transform, що змінює containing block для sticky.
Sticky — не глобальний; він контекстний. Аудитуйте стилі предків, а не сам липкий елемент.
9) Чи треба автопрокручувати бічну панель змісту, щоб активний пункт був видимим?
Це приємна функція, якщо зміст довгий. Робіть це лише коли потрібно (block: "nearest"), і не боріться з користувачем, якщо він вручну прокручує зміст.
10) Як це тестувати надійно?
Використовуйте детерміністичні сторінки: одна з великою кількістю заголовків, одна з великими зображеннями, одна з блоками коду, одна зі згортаннями.
Додайте автоматичні перевірки на зламані хеш‑посилання і дубльовані ID. Для підсвічування робіть браузерні тести, що прокручують до відомих відступів і перевіряють aria-current.
Висновок: наступні кроки, які можна відправити в реліз
Права панель змісту здається косметичною, поки не зламається і всі не стануть детективами UI.
Виправлення — не більше JS. Потрібно вибрати правильні примітиви і дотримуватися нудних правил.
- Додайте
scroll-margin-topдо заголовків з використанням змінної висоти хедера, що відповідає вашому липкому хедеру. - Забезпечте стабільні, унікальні ID заголовків (явні там, де важливо; детерміністичне слагування в інших випадках).
- Зробіть зміст липким через grid‑макет, і уникайте overflow‑обгорток, що саботують sticky.
- Використайте один IntersectionObserver для підсвічування активного, з rootMargin під хедер.
- Запровадьте CI‑гардрейли на відсутні ID, дубльовані ID і зламані in‑page хеші.
Якщо зробите лише дві речі: використайте scroll-margin-top і припиніть запускати важку логіку в обробнику прокрутки.
Це вже усуває більшість багів зі змістом, які я бачив у продакшні.