Ви вставляєте посилання на розділ у внутрішньому вікі, а колега потрапляє… десь поруч.
Або заголовок ховається під липким заголовком. Або якір змінюється при кожному деплої через те, що хтось «покращив» алгоритм генерації slug.
Тепер ви налагоджуєте посилання замість систем.
Якірні посилання у стилі документації виглядають тривіально — поки ви не почнете експлуатувати їх у великому масштабі протягом років контенту, через кілька рендерерів, у темній темі й у дизайн-системі, яка дуже любить липкі заголовки.
Це одна із тих «малих UI-фіч», яка стає проблемою доступності під час інциденту, коли ваші runbook-и неможливо швидко відкрити за глибоким посиланням.
Як мають відчуватися хороші якірні посилання (і чому це важливо SRE)
Хороший сайт документації робить посилання на секції передбачуваними. Наведіть курсор на заголовок — з’являється невеликий значок-посилання, ви клікаєте, URL оновлюється,
і ви можете вставити його в чат. Коли його відкривають, сторінка прокручується так, щоб заголовок стояв акуратно під липким заголовком.
Ніяких дивних стрибків, ніяких прихованих заголовків, ніякого «чому браузер показує не те місце?»
Це не просто полірування інтерфейсу заради краси. Це операційна спроможність. Runbook-и й постмортеми працюють лише настільки, наскільки їхні глибокі посилання надійні.
Під час заплутаного інциденту ви не хочете казати «прокрутіть до третього заголовка ‘Мітигація’». Ви хочете посилання, що потрапляє
точно на потрібний абзац.
Також: якіри — це контракт. Коли люди діляться ними, вони фактично стають API. Порушите їх — дізнаєтеся про це, зазвичай, коли
на нараді з керівництвом хтось скаже: «Посилання в runbook-і не працює».
Жарт №1: Якірні посилання схожі на ротації on-call — ніхто ними не цікавиться, доки вони не зламаються, а потім всі тільки й говорять про них.
Обов’язкові вимоги для «документних» якорів
- Стабільні ID: заголовки мають зберігати той самий
idпри перебудовах і дрібних правках тексту. - Прокрутка з урахуванням зсуву: липкі заголовки не повинні перекривати цільовий заголовок.
- Підказка при наведенні: значок пермалінку з’являється при наведенні/фокусі, а не завжди засмічує сторінку.
- Клікабельний заголовок або суміжний контрол: користувачі можуть скопіювати посилання на розділ без точних кліків.
- Доступна поведінка: фокус клавіатури, читач екрану, обробка зменшеного руху.
- Працює без JavaScript: базова навігація якорів має залишатися працездатною.
Факти та історія: чому якіри працюють так, як працюють
Якірні посилання виглядають сучасно, але їхній основний механізм старий і упертий. Це добре: нудні примітиви надійні.
Кілька конкретних фактів і контекстних точок, що пояснюють нинішні обмеження:
- Фрагменти у URL старіші за сучасний CSS: частина URL після
#fragmentвикористовується з ранніх стандартів вебу для орієнтації в сторінці. - Фрагменти — клієнтські: фрагмент не відправляється на сервер у HTTP-запиті, тому серверні логи не покажуть його, якщо ви не інструментуєте клієнт.
- Раніше використовувалися іменовані якіри: історично писали
<a name="foo">; у сучасному HTML використовуютьid="foo"на будь-якому елементі. - Дублікати ID — невизначена поведінка: браузери вибирають «перший» або «що DOM вийшов означати», що відрізняється та ускладнюється з гідрацією.
- CSS отримав реальний фікс для липких заголовків:
scroll-margin-topіscroll-padding-topз’явилися саме через поширення липких заголовків. - Сайти документації популяризували пермалінки при наведенні: MediaWiki і пізніші портали для розробників привчили користувачів очікувати пермалінки заголовків.
- Unicode ускладнює створення slug-ів: у
idможна вставляти не-ASCII, але сумісність і копіювання часто змушують команди використовувати ASCII-slug-и. - Браузери тепер підтримують «scroll to text»: деякі підтримують текстові фрагменти (
#:~:text=), але це не заміна стабільних ID і може бути крихким.
Рішення дизайну, які роблять якіри нудними — у доброму сенсі
Виберіть UX: клікабельний заголовок чи явна кнопка пермалінку
Два поширені шаблони:
- Клікабельний заголовок: весь заголовок — посилання на самого себе. Це швидко й помітно. Може дратувати користувачів, які просто хотіли виділити текст.
- Кнопка пермалінку поруч із заголовком: заголовок залишається звичайним текстом; значок-посилання з’являється при наведенні/фокусі. Класика сайтів документації. Рекомендація за замовчуванням.
У продукційних документах я віддаю перевагу явному контролю пермалінку, бо це розділяє «перейти/скопіювати посилання» і «виділити текст».
Менше випадкових кліків під час виділення заголовків.
Стратегія зсувів: спочатку CSS, JS — останній
Липкі заголовки створюють найпомітнішу помилку з якорями: браузер прокручує, але ціль ховається під заголовком.
Це можна виправити за допомогою JS, але також можна виправити в CSS і зберегти нативну поведінку браузера.
Використовуйте CSS, коли це можливо:
scroll-margin-topна заголовках: чисто, локально і працює для нормальної навігації по сторінці.scroll-padding-topна контейнері прокрутки: корисно, коли у вас макет із окремою областю для прокрутки.
JavaScript слід застосовувати лише коли у вас складні контейнери прокрутки, динамічна висота заголовка або старі браузери, які ви не можете відкинути.
Трактуйте ID заголовків як схемy
ID — не прикраса. Це стабільні ідентифікатори, на які посилаються:
- внутрішні посилання (TOC, перехресні посилання)
- зовнішні посилання (чат, таски, документація в інших системах)
- індекси пошукових систем
- автоматизація (лінтери, перевірники посилань, екстрактори документації)
Якщо ви змінюєте алгоритм генерації ID — це злам сумісності. Поводьтеся відповідно: версіонуйте, мігруйте, робіть редиректи там, де можливо, і комунікуйте.
Реалізація: іконки при наведенні, зсуви, клікабельні заголовки
Базова HTML-структура
Найкраща структура проста: заголовки мають id. Біля кожного заголовка рендериться невелике анкерне посилання
на #id. Анкер має бути фокусованим, мати читабельну мітку і бути візуально стриманим, поки не відбулося навЕДеннЯ/фокус.
cr0x@server:~$ cat heading-anchors.html
<article class="doc">
<h2 id="fast-diagnosis">
Fast diagnosis
<a class="permalink" href="#fast-diagnosis" aria-label="Permalink to Fast diagnosis">
<span aria-hidden="true">#</span>
</a>
</h2>
<p>Start with the obvious checks first.</p>
</article>
Той символ «#» може в житті бути SVG-значком ланцюжка. Зберігайте доступну мітку, а видимий значок робіть aria-hidden.
CSS: підказка при наведенні/фокусі та виправлення зсуву
Зробіть дві речі в CSS:
- Сховайте контрол пермалінку, поки заголовок не в наведенні або не в фокусі (але залишайте його доступним для клавіатурних користувачів).
- Застосуйте
scroll-margin-top, щоб якір опинявся під вашим липким заголовком.
cr0x@server:~$ cat anchors.css
:root {
--sticky-header-height: 64px;
}
.doc h2, .doc h3, .doc h4 {
scroll-margin-top: calc(var(--sticky-header-height) + 12px);
position: relative;
}
.doc .permalink {
margin-left: 0.5rem;
text-decoration: none;
opacity: 0;
transition: opacity 120ms linear;
}
.doc h2:hover .permalink,
.doc h3:hover .permalink,
.doc h4:hover .permalink,
.doc .permalink:focus {
opacity: 1;
outline: none;
}
.doc .permalink:focus-visible {
opacity: 1;
outline: 2px solid currentColor;
outline-offset: 2px;
}
Якщо висота заголовка змінюється залежно від брейкпоінтів, задайте --sticky-header-height у відповідних медіазапитах.
Не «вимірюйте в JS», якщо тільки ви зовсім не мусите.
Клікабельні заголовки: обережна версія
Якщо наполягаєте на тому, щоб увесь заголовок був клікабельним, зробіть це так, щоб не заграбати виділення тексту зловмисним <a>.
Один прийнятний компроміс: залишити текст заголовка як звичайний, а додати псевдоелемент-накладку з обмеженою зоною кліку.
Інший варіант: обгорнути, але додати CSS, який покращує виділення і залишає іконку пермалінку явним контролом.
Моє пряме правило: зробіть контрол пермалінку основною ціллю для кліку. Нехай заголовки залишаються заголовками.
Поведінка «Копіювати посилання» (і чому це важливо)
Деякі сайти додають окрему кнопку «копіювати посилання», яка записує повний URL у буфер обміну. Це зручно і зменшує
плутанину «я скопіював лише фрагмент». Але це не обов’язково.
Якщо реалізуєте, робіть прогресивно: атрибут href анкеру має працювати без JS.
cr0x@server:~$ cat copy-link.js
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-copy-permalink]');
if (!btn) return;
const id = btn.getAttribute('data-copy-permalink');
const url = new URL(window.location.href);
url.hash = id;
try {
await navigator.clipboard.writeText(url.toString());
btn.setAttribute('data-copied', 'true');
setTimeout(() => btn.removeAttribute('data-copied'), 1200);
} catch {
// Fallback: update location so users can copy from address bar
window.location.hash = id;
}
});
Clipboard API має особливості дозволів у вкладених контекстах. Ваш фолбек має все одно давати користувачеві скопіювати URL через адресний рядок.
Стабільні slug-и: частина, яку всі недооцінюють
Завдання: перетворити текст заголовка на стабільний id типу fast-diagnosis-playbook.
Підступ: заголовки змінюються, пунктуація змінюється, трапляються дублікати, і різні рендерери створюють slug-и по-різному.
Правильний підхід залежить від життєвого циклу контенту:
- Внутрішня документація з частими правками: дозволяйте явні ID у джерелі (розширення Markdown) і заохочуйте авторів фіксувати ID для важливих розділів.
- Публічна документація з зовнішніми посиланнями: стабільність ще важливіша — надавайте пріоритет явним ID для ключових заголовків і версіонуйте алгоритм slug-ів.
Розумний алгоритм для slug-ів (і правила, які треба задокументувати)
Виберіть правила і не імпровізуйте пізніше. Ось практичний набір:
- Нормалізуйте Unicode (NFKD) і видаліть комбінуючі знаки для ASCII-slug-ів.
- Перетворіть у нижній регістр.
- Замініть послідовності неалфавітно-цифрових символів на одиничні дефіси.
- Обріжте ведучі/кінцеві дефіси.
- Ведіть лічильник колізій:
heading,heading-1,heading-2. - Дозвольте явне переважання, наприклад
{#my-stable-id}у Markdown.
Якщо ви зміните ці правила — ви зламаєте посилання. Це не теорія. Таке станеться.
Надіслати фрагменти на сервер і надійні редиректи неможливі
Оскільки фрагменти не надсилаються на сервер, ви не можете надійно робити серверні редиректи «старий фрагмент → новий фрагмент» звичайним способом.
Можна зробити клієнтське мапування на завантаженні сторінки (прочитати location.hash, зіставити, встановити location.hash).
Це костиль для міграцій.
Не будуйте на цьому бізнес. Краще: тримайте ID стабільними.
Доступність і UX: не відправляйте миле — відправляйте придатне
Пермалінки заголовків — класичне місце, де можна випадково ускладнити життя клавіатурним і користувачам з читачами екрану.
Ви додаєте інтерактивні контролі поруч із заголовками, а заголовки вже є орієнтирами навігації.
Базові вимоги
- Клавіатура: контрол пермалінку має бути доступним через Tab і показувати видимий індикатор фокусу.
- Читачі екрану: посилання має зрозумілу мітку (наприклад, «Permalink to …»). Іконка — aria-hidden.
- Ціль кліку: не робіть зони кліку шириною 10px. Зробіть її комфортною для сенсорних пристроїв.
- Зменшений рух: уникайте хитромудрих анімацій прокрутки за замовчуванням; поважайте
prefers-reduced-motion.
Плавна прокрутка: обережно
Плавна прокрутка приємна, поки не стає поганою. Вона може спричиняти нудоту і погіршує відчуття «де я» в довгих документах.
Якщо вмикаєте її глобально — переконайтеся, що зменшений рух її вимикає.
cr0x@server:~$ cat motion.css
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
Проте: нативні скачки якорів швидкі та передбачувані. Я не вмикаю плавну прокрутку без наполягання дизайну.
Управління фокусом після переходу за якорем
Коли ви клікаєте пермалінк, URL змінюється і сторінка прокручується, але фокус клавіатури може лишатися на клікнутому посиланні.
Це нормально. Неприпустимо — опинитися на заголовку без видимого контексту фокусу, якщо ви прийшли клавіатурою або скриптом.
Прагматичне покращення: додати tabindex="-1" до заголовків, щоб їх можна було фокусувати програмно, а потім фокусувати ціль при події hashchange.
Робіть це лише після тестування з реальними технологіями доступності; не робіть «а11y-театру», який ламає поведінку.
SEO та аналітика: якіри — не шар маршрутизації
Фрагменти в URL не змінюють серверний маршрут, тому пошукові системи й аналітика поводяться інакше:
- Пошукові системи: зазвичай індексують URL сторінки; фрагменти іноді показуються як sitelinks, але це не окрема сторінка.
- Аналітика: серверна аналітика не бачить фрагменти. Клієнтська може, але її треба імплементувати.
- Канонічні URL: не встановлюйте каноніки з фрагментами; канонік має вказувати на базову сторінку.
Якщо вам важливо «які розділи найчастіше посилаються», додайте клієнтську інструментованість на hashchange і на кліки пермалінків.
Також: не засмічуйте pageview-ами кожну зміну hash. Трекати як подію.
Одна максима надійності зі світу інженерії Джона Остерхаута. Парафраз: «складність — корінь більшості програмних помилок» — John Ousterhout (парафраз).
Тримайте цю фічу нудною.
Практичні завдання: команди, виводи та рішення
Цей розділ навмисно операційний. Кожне завдання включає команду, що означає вивід, і яке рішення прийняти.
Приклади припускають статичний сайт у ./dist і джерело в ./src. Адаптуйте під своє сховище.
Завдання 1: Знайти дублікати ID у згенерованому HTML
cr0x@server:~$ rg -n ' id="' dist | sed -n 's/.* id="\([^"]\+\)".*/\1/p' | sort | uniq -d | head
getting-started
troubleshooting
Що значить вивід: щонайменше дві сторінки (або одна сторінка) містять однакові значення id. У межах однієї сторінки дублікати — баг коректності.
Рішення: якщо дублікати в одному HTML-файлі — виправте обробку зіткнень slug-ів. Якщо дублікати між сторінками — це нормально, якщо ви не вбудовуєте сторінки разом (SPA) або не маєте маршрутизації, що зливає DOM.
Завдання 2: Перевірити дублікати ID у кожному файлі
cr0x@server:~$ for f in dist/**/*.html; do
> ids=$(perl -nE 'say $1 while / id="([^"]+)"/g' "$f" | sort)
> dups=$(printf "%s\n" "$ids" | uniq -d)
> if [ -n "$dups" ]; then
> echo "DUP IDs in $f"
> echo "$dups" | head
> fi
> done
DUP IDs in dist/runbook.html
mitigation
Що значить вивід: у dist/runbook.html принаймні два елементи з id="mitigation".
Рішення: оновіть генератор slug-ів, щоб додавати суфікси при зіткненнях, або вимагайте явні ID для повторюваних заголовків, як-от «Mitigation» і «Rollback».
Завдання 3: Перевірити висоту липкого заголовка в обчисленому CSS
cr0x@server:~$ rg -n 'position:\s*sticky|position:\s*fixed' src/styles -S
src/styles/header.css:14:position: sticky;
src/styles/header.css:15:top: 0;
Що значить вивід: у вас є липкий заголовок. Ймовірно, він закриває цілі якорів без обробки зсувів.
Рішення: встановіть scroll-margin-top на заголовках або scroll-padding-top на контейнері прокрутки, використовуючи висоту заголовка.
Завдання 4: Підтвердити, що ви застосовуєте зсуви прокрутки десь
cr0x@server:~$ rg -n 'scroll-margin-top|scroll-padding-top' src -S
src/styles/anchors.css:6: scroll-margin-top: calc(var(--sticky-header-height) + 12px);
Що значить вивід: зсуви реалізовані в CSS.
Рішення: перевірте значення змінної по брейкпоінтах; якщо у вас кілька заголовків (банер + навігація), просумуйте їх.
Завдання 5: Перелічити внутрішньосторінкові посилання з хешами і перевірити наявність цілей
cr0x@server:~$ python3 - <<'PY'
import glob, re, sys
from collections import defaultdict
href_re = re.compile(r'href="#([^"]+)"')
id_re = re.compile(r' id="([^"]+)"')
for f in glob.glob("dist/**/*.html", recursive=True):
html = open(f, "r", encoding="utf-8").read()
hrefs = set(href_re.findall(html))
ids = set(id_re.findall(html))
missing = sorted(hrefs - ids)
if missing:
print(f"{f}: missing targets: {missing[:5]}")
PY
dist/index.html: missing targets: ['fast-diagnosis-playbook']
Що значить вивід: на сторінці є посилання на #fast-diagnosis-playbook, але жоден елемент не має такого ID (невідповідність TOC, зміна slug-а або відсутній контент).
Рішення: виправте рендерер так, щоб генерація TOC і генерація ID заголовків користувалися одним джерелом правди для slug-ів.
Завдання 6: Виявити зміну ID заголовків між збірками
cr0x@server:~$ git diff --name-only HEAD~1..HEAD | rg '\.md$' | head
src/docs/runbook.md
src/docs/storage.md
cr0x@server:~$ python3 - <<'PY'
import re, sys, pathlib
p = pathlib.Path("dist/runbook.html")
html = p.read_text(encoding="utf-8")
ids = re.findall(r'<h[2-4][^>]* id="([^"]+)"', html)
print("\n".join(ids[:20]))
PY
fast-diagnosis
common-mistakes
checklists
Що значить вивід: ви можете знімати знімок ID на сторінці і порівнювати між релізами. Цей приклад виводить перші 20 ID заголовків.
Рішення: додайте CI-завдання, яке фейлить, якщо ID змінюються для незмінених заголовків (потрібне відображення на джерело), або хоча б оповіщення при великій кількості змін.
Завдання 7: Перевірити, чи контролі пермалінків мають доступні мітки
cr0x@server:~$ rg -n 'class="permalink"' dist | head -n 3
dist/runbook.html:42: <a class="permalink" href="#fast-diagnosis">
dist/runbook.html:88: <a class="permalink" href="#common-mistakes" aria-label="Permalink to Common mistakes">
dist/runbook.html:132: <a class="permalink" href="#checklists" aria-label="Permalink to Checklists / step-by-step plan">
Що значить вивід: принаймні один пермалінк не має aria-label. Таке посилання буде оголошене як «link» або «#» без контексту.
Рішення: примусіть шаблон рендерера додавати aria-label. Не покладайтеся на підказки — їх не чують скрин-рідери.
Завдання 8: Переконатися, що існує стилізація focus-visible
cr0x@server:~$ rg -n ':focus-visible' src/styles -S
src/styles/anchors.css:22:.doc .permalink:focus-visible {
Що значить вивід: ви принаймні думаєте про клавіатурних користувачів.
Рішення: якщо відсутнє — додайте. Якщо є — перевірте контраст у темному режимі. Інакше отримаєте скарги про «ловушку фокусу» у корпоративних середовищах.
Завдання 9: Визначити, чи прокручується сторінка чи вкладений контейнер
cr0x@server:~$ rg -n 'overflow:\s*(auto|scroll)' src/styles -S | head
src/styles/layout.css:31:overflow: auto;
Що значить вивід: у вас, ймовірно, є вкладений контейнер прокрутки (поширене в оболонках типу app).
Рішення: застосуйте scroll-padding-top до цього контейнера замість (або крім) scroll-margin-top на заголовках.
Якщо якірні стрибки не потрапляють у потрібне місце — часто причина в вкладеній прокрутці.
Завдання 10: Аудит JavaScript, що «допомагає» з прокруткою якорів
cr0x@server:~$ rg -n 'location\.hash|hashchange|scrollIntoView' src -S
src/app/router.js:118:window.addEventListener('hashchange', onHashChange);
src/app/router.js:141:document.querySelector(hash).scrollIntoView({ behavior: 'smooth' });
Що значить вивід: кастомний код перехоплює навігацію по hash і прокручує вручну.
Рішення: переконайтеся, що код враховує липкі заголовки і вкладені контейнери прокрутки. Якщо ні — видаліть його і покладіться на CSS-зсуви.
Ручний код прокрутки часто призводить до подвійної прокрутки, неправильного зсуву і порушення prefers-reduced-motion.
Завдання 11: Перевірити стратегію обробки колізій у генераторі
cr0x@server:~$ rg -n 'slug|permalink|heading.*id' src -S | head
src/build/slugify.js:3:function slugify(text) {
src/build/markdown.js:88: heading.id = slugify(heading.text);
cr0x@server:~$ sed -n '1,140p' src/build/slugify.js
function slugify(text) {
return text.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
module.exports = { slugify };
Що значить вивід: цей slugify не обробляє колізії. Два однакові заголовки дадуть однакові ID.
Рішення: реалізуйте додавання суфіксів при колізіях на рівні сторінки і додайте тести. Також розгляньте нормалізацію Unicode, якщо у вас є не-ASCII заголовки.
Завдання 12: Підтвердити, що іконки пермалінків з’являються при наведенні і при фокусі клавіатурою
cr0x@server:~$ rg -n 'opacity:\s*0|opacity:\s*1|h2:hover.*permalink|permalink:focus' src/styles/anchors.css
12: opacity: 0;
18:.doc h2:hover .permalink,
21:.doc .permalink:focus {
Що значить вивід: іконка за замовчуванням прихована і стає видимою при наведенні і фокусі. Це правильний патерн.
Рішення: перевірте, що іконка також доступна на сенсорних пристроях (немає hover). Просте рішення — робити її завжди видимою на малих екранах через медіа-запит.
Завдання 13: Переконатися, що ID у виводі збігаються з TOC
cr0x@server:~$ python3 - <<'PY'
import re, pathlib
html = pathlib.Path("dist/runbook.html").read_text(encoding="utf-8")
toc = re.findall(r'<nav[^>]*aria-label="Table of contents"[\s\S]*?</nav>', html)
if not toc:
print("No TOC nav found")
raise SystemExit(0)
toc_html = toc[0]
toc_hrefs = set(re.findall(r'href="#([^"]+)"', toc_html))
heading_ids = set(re.findall(r'<h[2-4][^>]* id="([^"]+)"', html))
missing = sorted(toc_hrefs - heading_ids)
extra = sorted(heading_ids - toc_hrefs)
print("TOC missing targets:", missing[:10])
print("Headings not in TOC:", extra[:10])
PY
TOC missing targets: []
Headings not in TOC: ['appendix-debug-notes']
Що значить вивід: TOC відповідає цілям, але один заголовок не включено (можливо, навмисно).
Рішення: вирішіть, чи включати всі заголовки, чи лише певні рівні. Тримайте це послідовним; невідповідність плутає людей і ламає очікування.
Завдання 14: Перевірити, чи кешування не змушує людей потрапляти на старі якорі
cr0x@server:~$ curl -I -s https://docs.example.invalid/runbook | sed -n '1,12p'
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
Що значить вивід: довготривале immutable кешування HTML ризиковане, якщо HTML змінюється, а URL лишається тим самим. Користувачі можуть отримувати застарілі сторінки, де ID не збігаються з поточними посиланнями.
Рішення: кешуйте HTML помірно (або з перевіркою), а активи (JS/CSS) кешуйте агресивно з fingerprint-іменами. Якщо мусите сильно кешувати HTML — версуйте шлях.
Швидкий план діагностики
Коли якіри «не працюють», люди описують це нечітко: «посилання зламане». Це може означати десять різних режимів відмови.
Ось порядок триажу, що знаходить вузьке місце швидко.
Перше: чи існує цільовий ID у DOM?
- Якщо ви контролюєте сторінку: перегляньте джерело (або інспект елемент) і знайдіть ID.
- Якщо це згенерований контент: запустіть grep/riggrep по HTML-виводу (див. Завдання 5).
Якщо ID не існує — зупиніться. Це не баг прокрутки. Це невідповідність генерації, зміна slug-а або колізія.
Друге: чи ID не дублюється?
Дублікати ID можуть привести вас не туди, куди треба. Здається, що «якірна навігація нестабільна», бо іноді потрапляє на перший випадок,
іноді порядок DOM змінюється при гідрації.
Перевірте дублікати в кожному файлі (Завдання 2). Виправте колізії в генераторі.
Третє: чи контейнер прокрутки такий, як ви думаєте?
Якщо ваш основний контент прокручується всередині контейнера, нативні якорі можуть прокручувати сторінку, а не контейнер,
або вони можуть виглядати «неправильно», бо у контейнера не налаштований верхній відступ для прокрутки.
Знайдіть overflow: auto або scroll (Завдання 9). Застосуйте scroll-padding-top до фактичного контейнера прокрутки.
Четверте: зсув під липким заголовком (спочатку CSS)
Якщо якір існує і унікальний, але заголовок ховається під заголовком — це питання зсуву.
Використовуйте scroll-margin-top на заголовках. Уникайте JS-костилів, якщо висота не динамічна.
П’яте: JavaScript перехоплює навігацію по hash
Код роутера, код плавної прокрутки або аналітики можуть блокувати нативну поведінку якорів.
Знайдіть hashchange, preventDefault і scrollIntoView (Завдання 10).
Видаліть більшу частину цього коду. Справді. Нативна навігація по якорях пройшла десятки років тестування. Ваші 40 рядків «допомоги» — ні.
Типові помилки (симптом → корінь проблеми → виправлення)
Симптом: посилання потрапляє в правильний розділ, але заголовок прихований
Корінь проблеми: липкий заголовок перекриває контент, і ви не врахували це.
Виправлення: застосуйте scroll-margin-top до заголовків або scroll-padding-top до контейнера прокрутки. Тримайте зсув у CSS-змінних по брейкпоінтах.
Симптом: посилання потрапляє в інший розділ із такою ж назвою
Корінь проблеми: дублікати ID через повторювані заголовки (наприклад, кілька «Summary») без обробки колізій.
Виправлення: реалізуйте на сторінці додавання суфіксів при колізіях у генерації slug-ів; заохочуйте явні ID для операційних заголовків.
Симптом: якіри працюють локально, але падають у проді
Корінь проблеми: HTML сильно кешується або виробнича пайплайн-рендеринг генерує інші slug-и.
Виправлення: вирівняйте slug-генерацію між середовищами; кешуйте HTML з перевіркою; fingerprint-уйте активи; додайте перевірку стабільності ID під час збірки.
Симптом: якіри працюють при повному перезавантаженні, але не при навігації в SPA
Корінь проблеми: клієнтський роутер перешкоджає дефолтній поведінці hash або контент підвантажується після навігації, тому елемент ще не існує.
Виправлення: після завершення навігації, якщо існує location.hash, прокрутіть до цілі після рендеру. Краще використовувати scrollIntoView на цілі і мати обробку зсуву через CSS.
Симптом: посилання в TOC не збігаються з заголовками
Корінь проблеми: TOC генерується від сирого тексту заголовків, а ID — з обробленого тексту (або навпаки), або пайплайн використовує два різні slug-функції.
Виправлення: одна функція slug-ів як джерело правди. Експортуйте її як спільний модуль і тестуйте на «golden» кейсах.
Симптом: іконка при наведенні з’являється, але клавіатурні користувачі її не знаходять
Корінь проблеми: іконка прихована через display: none або показується лише при наведенні, а не при фокусі.
Виправлення: ховайте через opacity/visibility і показуйте на :focus / :focus-visible. Переконайтеся, що Tab досягає контролу.
Симптом: при копіюванні посилання іноді виходить повний URL, іноді лише #fragment
Корінь проблеми: користувачі копіюють із різних місць (адресний рядок vs. правий клік по посиланню vs. виділення), і ваш інтерфейс не підказує, що робити.
Виправлення: опціональний контрол «Копіювати посилання», що завжди копіює повний URL; інакше тримайте пермалінк як звичайний анкер, щоб копіювання через правий клік працювало.
Симптом: сторінка виглядає як музей іконок ланцюжків
Корінь проблеми: іконки пермалінків завжди видимі, включно з маленькими заголовками і густою референсною документацією.
Виправлення: показуйте при наведенні/фокусі і вибірково включайте для рівнів заголовків (зазвичай H2–H4). На мобайлі розгляньте завжди видимі але стримані іконки.
Жарт №2: Якщо ви думаєте, що дублікати ID «ймовірно не трапляться», вітаю — ви щойно створили найпоширенішу багу в компанії.
Чеклісти / покроковий план
Чекліст: впровадити документаційні якірні посилання за тиждень
- Виберіть патерн: кнопка пермалінку поруч із заголовками (рекомендація) або клікабельні заголовки. Вирішіть зараз.
- Визначте правила slug-ів: запишіть їх у репозиторії. Включіть обробку колізій і поведінку Unicode.
- Реалізуйте одну функцію slug: використовуйте її для ID заголовків, TOC і генерації перехресних посилань.
- Увімкніть явні ID: дозволіть авторам фіксувати ID для критичних розділів (runbook-и, SOP-и, юридичні документи).
- CSS-зсув: застосуйте
scroll-margin-topі задайте--sticky-header-heightпо брейкпоінтах. - Підказка при наведенні/фокусі: показуйте пермалінк при наведенні і при
:focus-visible. - Пробіг доступності: aria-label, комфортні зони кліку, стилі фокусу, зменшений рух.
- CI-валідація: фейл збірки при дубліктах ID в одному файлі і невідповідностях TOC.
- Перевірка політики кешування: уникайте довготривалого immutable кешування HTML, якщо ви не версуєте шляхи.
- План міграції: якщо змінюєте логіку slug-ів, вирішіть, як зберегти старі ID або мапити їх на клієнті.
Чекліст: CI-гейти, що реально ловлять регресії якорів
- Дублікати ID у кожному HTML-файлі (жорстке фейлення).
- TOC href-цілі існують (жорстке фейлення).
- Контроли пермалінків мають
aria-label(жорстке фейлення). - Опціонально: виявлення великого обсягу зміни ID порівняно з попереднім релізом (м’яке фейлення / оповіщення).
- Опціонально: гарантія, що певні типи документів не мають заголовків без ID (runbook-и, хендбуки).
Три корпоративні міні-історії (анонімізовано, технічно точно)
Міні-історія 1: інцидент через хибне припущення
Середня компанія мала внутрішній сайт «Production Runbook», згенерований з Markdown у оболонку SPA.
Там були пермалінки на заголовки і TOC. Всі довіряли цьому. Сайт пережив кілька реструктуризацій організації — фактично безсмертний.
Потім вони переробили верхню навігацію: липкий заголовок виріс з одного рядка до двох, плюс банер інциденту, що з’являвся під час великих подій.
Зміни в фронтенді відправили у п’ятницю ввечері, бо CSS-диф здавався нешкідливим і ніхто не хотів гальмувати реліз.
Хибне припущення: «якірні посилання все одно потраплятимуть правильно; браузер це обробить».
У понеділок стався інцидент. Хтось вставив посилання на секцію «Disable autoscaling». Сторінка завантажилась, прокрутилась і… заголовок ховався.
У спокійному режимі це неприємність. Під час інциденту це стало проблемою координації:
троє людей думали, що дивляться одні й ті самі інструкції, але двоє насправді читали попередній розділ.
Проблема полягала не в самому липкому заголовку. Вона була в тому, що зсув був захардкоджений під стару висоту заголовка.
Гірше, банер інциденту з’являвся лише в продакшені, тож локальні тести цього не бачили.
Виправлення було нудне й ефективне: scroll-margin-top на заголовках з CSS-змінною, якої задає компонент заголовка,
і додаткова змінна для банера, коли він присутній. Ніякого JS-математики для прокрутки, ніякого трешу макету.
Міні-історія 2: оптимізація, що відбилася боком
Інша організація намагалася «оптимізувати» рендер документації. Вони винесли генерацію slug-ів у спільний пакет, який використовували продукти.
Добра ідея. Потім вони «покращили» алгоритм: раніше він зберігав дефіси і зводив пробіли; тепер нормалізував більше пунктуації,
видаляв стоп-слова і обрізав до максимальної довжини «для чистоти».
Зміна скоротила довгі уродливі ID. Водночас вона знищила стабільність пермалінків.
Заголовки типу «How to roll back: API gateway» і «How to roll back – API gateway» злилися в один ID.
Суфіксування колізій не було реалізовано, бо бібліотека була «чистою» і не тримала стану сторінки.
Першим сигналом були не скарги читачів документації. Це були інженери on-call, які скаржилися, що посилання в старих інцидент-тікетах не працюють.
Ось особливість глибоких посилань: їх використовують зайняті і роздратовані люди — саме тих, кого ви не маєте дратувати.
Відкочування алгоритму виявилось складнішим, ніж думали. Сторінки вже були поділені зовнішньо з партнерами.
Вони врешті-решт реалізували клієнтське мапування фрагментів для найпоширеніших старих ID і повернули попередній алгоритм з прапорцем версії.
Також додали суфіксування колізій. «Оптимізація» коштувала тижнів і частини довіри.
Міні-історія 3: нудна практика, що врятувала ситуацію
Великий підприємець мав платформу контенту, що генерувала кілька виходів: публічні docs, внутрішні docs, PDF і офлайн HTML-бандл
для обмежених середовищ. Це поле мін було для якорів, бо кожен рендерер хотів робити по-своєму.
Вони зробили одну вкрай нудну річ: написали «Договір ID заголовків» і трактували його як API.
Він визначав правила slug-ів, поведінку колізій і коли явні ID обов’язкові. Також включав тестові вектори:
рядки з пунктуацією, Unicode, повторювані заголовки і кейси на кшталт «C++» і «S3 / IAM».
Потім це наказали виконувати в CI по всіх виходах. Не «намагайтеся», а жорстке фейлення.
Кожен рендерер мав використовувати ту саму функцію slug, і кожна збірка запускала перевірку дублікатів ID та валідацію TOC.
Через місяці вони мігрували оболонку сайту, включно з новим липким заголовком і новим Markdown-пайплайном.
Міграція була хаотичною — окрім пермалінків, які лишалися стабільними. Старі тікети і runbook-и продовжували працювати.
Ось як виглядає «нудно, але правильно»: ніхто їм не дякував, і продакшн не загорівся.
Питання й відповіді
Чи ставити id на сам елемент заголовка чи на вкладений анкер?
Ставте його на елемент заголовка (наприклад, <h2 id="...">), якщо ваш рендерер не ускладнює це.
Це семантично чисто і добре працює з scroll-margin-top.
Чи потрібен JavaScript, щоб пермалінки при наведенні працювали?
Ні. Поведінка при наведенні/фокусі — справа CSS. JavaScript потрібен опціонально для «копіювати посилання» в буфер і для особливих випадків SPA-таймінгу.
Який найкращий спосіб обробити зсув для липкого заголовка?
Спочатку CSS: scroll-margin-top на заголовках і/або scroll-padding-top на контейнері прокрутки.
JS-зсув — крайній засіб.
Чому деякі якіри працюють лише після повного перезавантаження у SPA?
Бо елемент ще не в DOM, коли відбувається навігація за hash, або роутер перешкоджає дефолтній поведінці.
Виправляйте, прокручуючи після рендеру і переконавшись, що ваш контейнер — це саме той, що прокручується.
Як переконатися, що ID заголовків не змінюються при редагуванні тексту заголовка?
Дозвольте явні ID в авторингу (наприклад, розширення Markdown) і використовуйте їх для «стабільних посилань».
Інакше будь-яка система генерації slug-ів із тексту зміниться разом із текстом.
Чи можна використовувати не-ASCII символи в ID?
Браузери це витримають, але сумісність інструментів (лінтери, процесори, копіювання) краща з ASCII.
Якщо у вас мультимовний контент — розгляньте явні ID або надійну стратегію нормалізації Unicode.
Чи можна «редиректити» старі якіри на нові?
Не на сервері звичайним способом, бо фрагменти не надсилаються на сервер.
Можна робити клієнтське мапування при завантаженні, читаючи location.hash і переписуючи його, але це — міграційний костиль, не фундамент.
Чи має іконка пермалінку бути завжди видимою?
Зазвичай ні: це додає шуму. Показуйте при наведенні і при :focus-visible. На сенсорних пристроях розгляньте завжди видиму, але стриману іконку або показ на тап з більшою зоною кліку.
Як тестувати це без повного набору автоматизації браузера?
Почніть зі збірково-часових перевірок HTML: дублікати ID, відсутні цілі, наявність aria-label. Потім ручна перевірка: таб-навiгація, чи заголовок стає видимим під липким заголовком, і цільові тап-зони на мобайлі.
Що найчастіше команди забувають про якіри?
Що ID стають зовнішніми залежностями. Люди вставляють їх у тікети, чат, постмортеми та автоматизацію. Міняти їх по-легковажному — все одно, що перейменувати публiчний метод API.
Висновок: кроки на наступний тиждень
Якщо ви хочете якірні посилання, що відчуваються як справжній сайт документації, перестаньте ставитися до них як до прикраси.
Це навігація, інструмент для співпраці і інфраструктура для інцидентів — просто в UI-обгортці.
Зробіть ці кроки:
- Реалізуйте CSS-зсуви (
scroll-margin-top) пов’язані з вашою змінною висоти липкого заголовка. - Уніфікуйте slug-генерацію з обробкою колізій і єдиною реалізацією.
- Додайте CI-перевірки на дублікати ID і невідповідності TOC-цілей.
- Зробіть пермалінки доступними: мітки,
focus-visibleі комфортні зони кліку. - Визначте політику стабільності: явні ID для runbook-ів і «довговічних» документів.
Ви випустите щось, що виглядає як сайт документації. І ще важливіше — щось, що поводиться як такий під навантаженням.