Мега-меню на CSS Grid: наведення, фокус, мобільні пристрої та основи доступності
Ваша маркетингова команда хоче «просте» мега-меню. Потім користувач клавіатури не може дістатися до половини посилань, мобільні натискання відкривають і закривають як зла ліфт,
і хтось створює баг під назвою «меню зникає, коли я на нього дихаю».
Ось як побудувати мега-меню, що поводиться як дорослий: передбачуване наведення і фокус, розумна мобільна поведінка та базова доступність, яка не соромитиме вас під час перевірки на відповідність.
Що таке мега-меню (а чим воно не є)
Мега-меню — це шаблон навігації, коли елемент верхнього рівня відкриває велику панель, що містить кілька груп посилань — часто розташованих у колонках,
іноді з рекомендованим контентом, заголовками, іконками, промо або «популярними» скороченнями. Ключове слово — група. Мета мега-меню — показати
багато пунктів призначення, не загубивши користувача в лабіринті кліків.
Чим воно не є: смітником для всіх URL, які коли-небудь створила ваша організація. Якщо ваше мега-меню виглядає як CSV-експорт, проблема в інформаційній архітектурі, а не в CSS.
Також: мега-меню — це не «меню» в сенсі ARIA з role="menu", якщо ви не будуєте віджети меню в стилі застосунку.
Більшість сайтів потребують звичайної навігації: списків посилань у <nav>, з кнопкою розкриття або посиланням, що відкриває панель.
Не змушуйте користувачів допоміжних технологій удавати, що вони в настільному застосунку.
Правило з відтінком позиції
Якщо ваша мега-панель потребує більше одного прокручування на ширшому екрані ноутбука, це вже не мега-меню. Це карта сайту з триггером hover.
Виправте таксономію або додайте сторінку «Всі продукти».
Цікаві факти та історичний контекст
Мега-меню не з’явилися тому, що дизайнери масово вирішили ускладнити інтерфейс. Вони — реакція на масштаб: більше розділів, більше продуктових ліній,
більше поглинань і стійка фантазія, що навігація може компенсувати організаційний хаос.
aria-expanded для контролів розкриття (кнопок, що відкривають панелі) — один із найсильніших сигналів, який ви можете додати для користувачів скрінрідерів. Він підказує, що сталося.:focus-within (широко доступний з кінця 2010-х) дав CSS практичний спосіб тримати дропдауни відкритими, поки фокус знаходиться всередині, без JS.prefers-reduced-motion (адаптація ~2019) перевела «анімації меню всюди» в розмову про доступність, а не лише про продуктивність.inert зараз підтримується достатньо широко, щоб бути корисним для відключення фону під час оверлеїв, але він не дає права пропускати тестування клавіатури.
Цитата стосується розподілених систем, але вона також справедлива для UI-компонентів. Ваше мега-меню ламається на пристрої, який ви не тестували, з режимом введення, який ви забули,
у мові, що змусила колонки перенестися, за банером, що змінив ваш stacking context. Плануйте відмови. Дизайнуйте нудний шлях.
Архітектура: спочатку розмітка, потім CSS Grid
Найшвидший спосіб побудувати недоступне мега-меню — почати з CSS. Другий найшвидший — почати з JavaScript. Почніть з семантики:
список посилань. Потім додайте контрол розкриття, щоб показати більше посилань. Потім стилізуйте. І додайте лише стільки JavaScript, скільки потрібно для мобільних пристроїв і стану.
Розмітка, яка вам справді потрібна
Для веб-навігації зазвичай потрібна така структура:
<nav aria-label="Primary">для регіону.<ul><li>для елементів верхнього рівня.<button>для переключення панелі (краще підходить для поведінки «відкрити/закрити») або посилання, якщо воно переміщує.- Контейнер панелі (зазвичай
<div>), що містить заголовки та групи посилань.
Якщо елемент верхнього рівня і навігує, і відкриває панель, виберіть одну поведінку. «Він робить обидва» перетворюється на «він нічого не робить» для користувачів з клавіатурою.
Поширене рішення: елемент верхнього рівня — посилання, плюс окрема кнопка розкриття поруч із ним.
Хто володіє станом: CSS для наведення/фокусу, JS для тапу
Використовуйте CSS для:
- Відкриття при наведені на пристроях з тонким вказівником (
@media (hover:hover)) - Тримання відкритим, поки фокус всередині (
:focus-within)
Використовуйте JavaScript для:
- Перемикання відкриття/закриття при тапі/кліку на малих екранах
- Встановлення
aria-expandedі, за потреби,hidden - Закриття по Escape
- Закриття при кліку поза елементом (обережно; не ігноруйте легітимні кліки)
Щоночі невеликий жарт (1/2)
Якщо ваше меню потребує 200-рядкової машини станів, вітаю: ви побудували розподілену систему, але для розчарування.
Поводження при наведені та фокусі без флікеру
Наведення — це зручність, а не фундамент. Воно має бути додатковим: корисне для миші, неістотне для торкання і ніколи не є єдиним способом показати посилання.
Дві помилки, які ви відправите, якщо не будете обережні:
- Флікер: меню відкривається, а потім закривається, коли вказівник рухається.
- Втрата фокусу: користувач клавіатури табує до триггера, панель відкривається, а потім згортається, коли фокус переходить до панелі.
Виправте втрату фокусу за допомогою :focus-within
Найцінніший селектор CSS у всьому проєкті — це :focus-within. Застосуйте його до елемента списку, що містить і триггер, і панель.
Коли будь-який нащадок отримує фокус, панель залишається відкритою.
У демо вгорі це правило виконує роботу:
cr0x@server:~$ cat snippets/nav.css | sed -n '1,18p'
.nav > ul > li:hover > .panel,
.nav > ul > li:focus-within > .panel{
display:block;
}
.panel::before{
content:"";
position:absolute;
left: 0;
top: -10px;
height: 10px;
width: 100%;
}
Місток через ::before — старомодний підхід, але він працює. Він створює «зону безпеки», щоб вказівник міг перейти від триггера до панелі без виходу з області наведення.
Без нього ви отримаєте звіти «меню зникає, коли я намагаюся ним користуватися». Ці звіти правдиві.
Не відкривайте при наведені для торкання
Браузери на сенсорних пристроях іноді емулюють наведення способами, приємними лише нікому. Використовуйте медіа-запити, щоб обмежити поведінку наведення:
@media (hover:hover) and (pointer:fine)→ дозволити поведінку hover.@media (hover:none)→ покладатися на явний перемикач.
Таймінгові хаки: уникайте, якщо не треба
Класичний підхід — додати затримку закриття (наприклад, 150ms), щоб невеликий дрейф вказівника не закривав меню. Це приємно… поки ні.
Затримки можуть зробити інтерфейс «липким» і вводять нестабільність, яку автоматичні тести люблять підсилювати.
Віддавайте перевагу геометричним виправленням (місток, розумне розташування панелі, достатній розмір триггера) над таймерами. Таймери — останній засіб.
Мобільна поведінка: тап, прокрутка і «будь ласка, не закривайте мене»
Мобільні пристрої — місце, куди мега-меню найчастіше потрапляють у непрацездатний стан. Не тому, що це неможливо, а тому, що команди намагаються зберегти десктопну поведінку замість зустріти платформу посередині.
На мобільних мега-меню зазвичай є одним із двох:
- вкладений акордеон всередині нав-ящика, або
- складений список, де натискання секції розгортає груповані посилання на місці.
Виберіть модель і дотримуйтеся її
Ось дві моделі, що працюють у продакшені:
| Модель | Як це відчувається | Коли використовувати |
|---|---|---|
| Вбудований акордеон | Секції розгортаються вниз по сторінці; без оверлея. | Коли верхня навігація вже в потоці й ви хочете уникнути проблем із блокуванням прокрутки. |
| Нав-ящик + акордеон | «Бургер» відкриває ящик; секції розгортаються всередині. | Коли потрібен простір і ви хочете сховати складність. |
Блокування прокрутки: найпопулярніший футган
Якщо ви використовуєте оверлей/ящик, ви можете заблокувати прокрутку тіла. Робіть це обережно, інакше викличете баги iOS Safari, зламану поведінку «нагорі сторінки» або дивні стрибки прокрутки при закритті.
Якщо ваша навігація не потребує оверлея, уникайте блокування прокрутки взагалі. Найкраще блокування — те, яке ви не реалізували.
Поведінки закриття, що поважають людей
- Escape закриває відкриту панель/ящик.
- Натискання поза елементом закриває його (але тільки коли це оверлей; вбудовані акордеони не повинні згортатись через тап десь ще).
- Фокус має переміщуватися у відкритий вміст при відкритті ящика й повертатися до триггера при закритті.
Зменшення руху та продуктивність
Якщо ви анімуєте панель, робіть це стримано і швидко. І обмежте анімацію:
- Дотримуйтеся
prefers-reduced-motion: reduce. - Уникайте анімації властивостей макета, що викликають перерахунок (наприклад, height від auto). Якщо анімуєте — віддавайте перевагу opacity і transform.
Щоночі невеликий жарт (2/2)
«Просто додайте размиття позаду меню» — це як перетворити проблему навігації в програму для бенчмаркінгу GPU.
Основи доступності: ролі, підписи та очікування
Доступність — це не вайб. Це контракт: користувачі клавіатури повинні дістатися до всього, користувачі скрінрідерів повинні розуміти зміну стану, і кожен повинен мати можливість вийти.
«Працює на моєму трекпаді» — не проходить як тест.
Використовуйте семантику навігації, а не меню застосунку
Для заголовка сайту дотримуйтеся:
<nav aria-label="Primary"><ul><li><a>для посилань<button aria-expanded aria-controls>для дисклозурів
Уникайте role="menu", якщо ви не будуєте справжній віджет меню з навігацією стрілками і ролями menuitem. Додавши ролі меню, ви успадковуєте очікувані правила взаємодії.
Багато команд додають ролі й пропускають поведінку. Це гірше, ніж нічого.
Кнопки розкриття: мінімальний набір ARIA
Кнопка, що відкриває панель, повинна мати:
aria-expanded="false|true", що відображає станaria-controls="panel-id", що вказує на елемент панелі- Доступну назву («Продукти», а не «Стрілка»)
Сама панель може бути звичайним контейнером. Якщо вона завжди в DOM, можна використовувати hidden при згорнутому стані, що видаляє її з дерева доступності й порядку табуляції.
Уникайте «візуально прихованих але доступних для фокусу» станів; вони призводять до того, що користувачі клавіатури табують у порожнечу.
Управління фокусом: як має відчуватися «добре»
Для десктопних меню з наведенням/фокусом:
- Tab до триггера: панель відкривається.
- Tab переміщається у посилання панелі: панель залишається відкритою.
- Shift+Tab повертає до триггера: панель залишається відкритою, поки фокус не покине весь компонент.
Для мобільних ящиків:
- Відкриття ящика переміщує фокус на перший фокусований елемент всередині.
- Закриття повертає фокус до кнопки-бургера.
- Фон не має бути фокусованим, поки ящик відкритий (використовуйте
inertабо фокус-треп, але реалізуйте правильно).
Точки торкання та відступи
Заголовки та посилання вашого мега-меню не повинні бути крихітними. Фізика «товстого пальця» непереможна. Давайте посиланням відступи; це не марнотратство простору, це запас від помилок.
Також не розміщуйте кілька крихітних контролів (посилання + стрілка + бейдж) у рядку висотою 32px і називайте це «чисто».
Шаблони CSS Grid для мега-панелей
Grid — правильний інструмент тут, бо мега-панелі — двовимірні макети: колонки груп, іноді з рекомендованими блоками, іноді з зображеннями.
Flexbox підходить для одномірного вирівнювання; він стає латкою, коли потрібні стабільні колонки, які не розсипаються в певних ширинах.
Шаблон 1: фіксована рекомендована колонка + пластичні колонки посилань
Поширений шаблон: один блок «рекомендований» (опис, CTA, можливо зображення) і дві колонки посилань. Використовуйте:
grid-template-columns: 1.4fr 1fr 1frдля десктопу- колапс до
1frна мобільних
Шаблон 2: auto-fit для невідомої кількості груп
Якщо ваш CMS може виводити 3–8 груп і ви не контролюєте це суворо (ознака проблеми, але поширена), використовуйте:
repeat(auto-fit, minmax(180px, 1fr)).
Це дозволяє колонкам акуратно переноситись без необхідності жорстко кодувати брейкпоінти для кожного варіанту контенту. Це не магія: довгі заголовки все одно будуть переноситися.
Але це деградує професійно, а не як сюрприз.
Шаблон 3: тримайте заголовки з їхніми першими посиланнями
Підступна помилка макета: заголовок групи опиняється внизу однієї колонки, а його посилання — вгорі наступної після переносу. Вирішіть це, зробивши кожну групу одним елементом сітки:
заголовок і його список мають належати одному контейнеру.
Контексти накладання та проблема «чому воно за заголовком»
Мега-меню часто «зникають» за банерами, липкими хедерами або герой-блоками. Зазвичай це не тільки z-index; це stacking contexts, створені:
position+z-indexна предкахtransformна предках (створює новий stacking context)filter,opacity,mix-blend-modeподібно
Коли налагоджуєте це, не підвищуйте z-index до 999999 випадково. Так ви отримаєте сайт, де все поверх усього, назавжди.
Знайдіть stacking context і виправте корінь проблеми.
Практичні завдання: команди, результати та рішення
Меню — фронтенд, але його надійна доставка все одно системна робота: тестуйте, вимірюйте, моніторте та не довіряйте лише очам.
Нижче — практичні завдання, які можна виконати локально або в CI, щоб упіймати звичні катастрофи. Кожне містить команду, що означає вивід, і яке рішення приймати далі.
Завдання 1: Підтвердити цілі підтримки браузерів (базова санітарна перевірка)
cr0x@server:~$ cat package.json | jq '.browserslist'
[
"defaults",
"not IE 11",
"maintained node versions"
]
Що означає вивід: Ви не заявляєте підтримку IE 11. Добре; Grid і сучасні селектори не вимагатимуть хаків.
Рішення: Якщо потрібно підтримувати IE 11 (рідко, але все ще буває), зупиніться і переробіть: ви не будуєте те саме мега-меню.
Завдання 2: Запустити локальну збірку і переконатися, що CSS поставляється
cr0x@server:~$ npm run build
> web@1.0.0 build
> vite build
vite v5.0.0 building for production...
dist/assets/index-3f2c7f1a.css 42.31 kB │ gzip: 8.90 kB
dist/assets/index-b0d1c8ad.js 182.12 kB │ gzip: 58.70 kB
✓ built in 2.54s
Що означає вивід: CSS існує, не надто крихітний і бандлиться.
Рішення: Якщо CSS відсутній або надто малий, перевірте пайплайн збірки на предмет очищення/трі-шейкінгу, що видаляє стилі навігації (поширено при генерації класів).
Завдання 3: Виявити випадкове видалення стилів фокусу
cr0x@server:~$ rg -n "outline:\s*none" dist/assets/index-*.css | head
1221:.nav-link:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(122,162,255,.45);background:rgba(255,255,255,.06)}
Що означає вивід: Outline видалено, але замінено видимим індикатором фокусу (box-shadow).
Рішення: Якщо знайдете outline:none без заміни, відкличіть зміну. Користувачі клавіатури подадуть баг, з яким ви не зможете сперечатися.
Завдання 4: Переконатися, що панель не фокусована, коли «закрита» (аудит DOM)
cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html=require('fs').readFileSync('dist/index.html','utf8'); const d=new JSDOM(html).window.document; console.log(d.querySelectorAll('.panel a').length);"
18
Що означає вивід: Посилання в панелі існують; тепер переконайтеся, що ваш рантайм використовує hidden або умовний рендер, коли панель закрита.
Рішення: Якщо панелі завжди доступні в порядку табуляції при закритті, реалізуйте переключення hidden у JS для мобільних/явних перемикачів.
Завдання 5: Лінт для невідповідностей ARIA (дешевий виграш у CI)
cr0x@server:~$ npx eslint src/nav/**/*.tsx
src/nav/MegaMenu.tsx
88:17 error aria-controls value must match an element id jsx-a11y/aria-props
✖ 1 problem (1 error, 0 warnings)
Що означає вивід: Контрол посилається на id панелі, якого не існує (або який змінюється під час рендеру).
Рішення: Виправте id, щоб вони були стабільними та унікальними. Ніколи не відправляйте aria-controls, що вказує в нікуди; це хибна обіцянка.
Завдання 6: Запустити Lighthouse локально і прочитати підказки стосовно навігації
cr0x@server:~$ npx lighthouse http://localhost:4173 --only-categories=accessibility,performance --output=text
Performance: 86
Accessibility: 94
Diagnostics:
Avoid enormous network payloads (main-thread impact)
Accessibility audits:
Buttons do not have an accessible name (1)
Що означає вивід: Одна кнопка не має доступної назви (часто кнопка розкриття зі стрілкою).
Рішення: Додайте доступну назву через текст, aria-label або aria-labelledby. Не відправляйте кнопки лише з іконками без підпису.
Завдання 7: Запустити axe проти сторінки (точніший сигнал a11y)
cr0x@server:~$ npx @axe-core/cli http://localhost:4173 --tags wcag2a,wcag2aa
Running axe-core 4.x
Violations:
1) aria-required-attr: Required ARIA attributes must be provided
- .menu-toggle (aria-expanded missing)
Що означає вивід: Ваш контрол розкриття відсутній обов’язковий атрибут стану.
Рішення: Додайте aria-expanded і оновлюйте його при перемиканні. Це не опціонально, якщо у вас є згортана область.
Завдання 8: Перевірити, що поведінка hover обмежена пристроями з вказівником/hover
cr0x@server:~$ rg -n "@media\s*\\(hover:hover\\)" src/styles/nav.css
148:@media (hover:hover) and (pointer:fine){
Що означає вивід: Ви явно обмежуєте правила hover.
Рішення: Якщо правила hover глобальні, отримаєте дивні ефекти на дотик. Обгорніть їх і реалізуйте click-to-toggle для малих екранів.
Завдання 9: Вловити зсуви макета при відкритті меню (CLS)
cr0x@server:~$ npx playwright test -g "mega menu does not shift layout"
Running 1 test using 1 worker
✓ 1 [chromium] › nav.spec.ts:14:1 › mega menu does not shift layout (2.3s)
Що означає вивід: Ваш тест стверджує, що висота хедера не змінюється і контент не стрибає при відкритті панелей.
Рішення: Якщо тест падає, віддайте перевагу абсолютно позиціонованим панелям на десктопі (оверлей) або цілеспрямовано резервуйте місце. Не дозволяйте всій сторінці перетікати при hover.
Завдання 10: Інспектувати проблеми stacking context через обчислені стилі (тріаж z-index)
cr0x@server:~$ node -e "console.log('Check in DevTools: does any ancestor have transform/filter/opacity < 1? If yes, you created a stacking context.')"
Check in DevTools: does any ancestor have transform/filter/opacity < 1? If yes, you created a stacking context.
Що означає вивід: Це нагадування, не автоматизація. Stacking contexts легше відлагоджувати в DevTools візуально.
Рішення: Якщо панель за чимось, приберіть transform/filter предка або перемістіть панель у портал верхнього рівня документа.
Завдання 11: Переконатися, що ключові ресурси не блокують першу взаємодію (TTI-ish перевірка)
cr0x@server:~$ npx webpack-bundle-analyzer dist/stats.json
Webpack Bundle Analyzer is started at http://127.0.0.1:8888
Use Ctrl+C to close it
Що означає вивід: Ви можете візуально побачити, чи витягнув мега-меню UI-бібліотеку розміром із маленький місяць.
Рішення: Якщо меню коштує забагато JS, рефакторьте: CSS для десктопної взаємодії, мінімум JS для перемикачів і відкладіть некритичну аналітику в хедері.
Завдання 12: Перевірити, що переключення меню реагує на Escape (поведінковий тест)
cr0x@server:~$ npx playwright test -g "escape closes open mega menu"
Running 1 test using 1 worker
✓ 1 [chromium] › nav.spec.ts:41:1 › escape closes open mega menu (1.8s)
Що означає вивід: Escape закриває відкриту панель і коректно відновлює фокус (ваш тест має це перевіряти).
Рішення: Якщо тест падає, реалізуйте обробник keydown на кнопці/контейнері панелі і забезпечте відновлення фокусу.
Завдання 13: Знайти відсутні фокусовані елементи в відкритій панелі (аудит порядку табуляції)
cr0x@server:~$ npx playwright test -g "tab reaches first link in opened panel"
Running 1 test using 1 worker
✘ 1 [chromium] › nav.spec.ts:62:1 › tab reaches first link in opened panel (2.0s)
Error: expected "body" to match /a.panel-link/
Що означає вивід: Після відкриття tab не перейшов у панель; фокус опинився в body або на не-меню елементі.
Рішення: Перевірте, чи панель не має display:none у невірний момент, або чи глобальний фокус-треп не краде фокус.
Завдання 14: Підтвердити, що CSS Grid дійсно застосований (а не перевизначений)
cr0x@server:~$ rg -n "display:\s*grid" dist/assets/index-*.css | head -n 3
1304:.panel-inner{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:14px}
Що означає вивід: Стилі Grid існують у збірці.
Рішення: Якщо Grid відсутній, можливо ви відправляєте застарілий стильовий файл або стилі компонента scoped неправильно.
Завдання 15: Перевірити відсутність випадкового вимкнення pointer-events
cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles | head
src/styles/animations.css:22:.no-pointer{pointer-events:none}
Що означає вивід: У вас є клас, що вимикає pointer events; його випадкове застосування може зняти клікабельність панелей або оверлеїв.
Рішення: Переконайтеся, що його не використовують для контейнерів навігації. «Меню не клікається» — часто самостворена помилка.
Швидкий план діагностики
Коли хтось пише «мега-меню зламалося» за п’ять хвилин до релізу, у вас немає часу філософствувати. Потрібен короткий шлях до вузького місця.
Ось порядок, що найшвидше знаходить корінь проблеми в реальних системах.
Перше: невідповідність режиму введення (hover vs tap vs keyboard)
- Перевірте на телефоні: чи відкривається при тапі надійно, і чи залишається відкритим під час прокрутки?
- Перевірте клавіатурою: табуйте до триггера, табуйте у панель; чи залишається відкритим?
- Перевірте трекпад/миш: чи спричиняє діагональний рух флікер?
Якщо один режим відмовляє, ви, ймовірно, зв’язали поведінку з hover, або не реалізували :focus-within, або не дали явний стан для мобільних.
Друге: видимість і stacking context (клас «він є, але я не бачу»)
- У DevTools інспектуйте елемент панелі. Чи він в DOM? Чи це
display:none? - Перевірте обчислений
z-indexі чи створює предок stacking context (transform,filter). - Шукайте
overflow:hiddenна контейнерах хедеру, що обрізають панель.
Якщо панель існує, але за чимось або обрізана, не підвищуйте z-index бездумно. Виправте stacking context або перемістіть контейнер панелі.
Третє: дрейф фокусу та станів ARIA
- Чи
aria-expandedвідображає видимий стан? - Чи панель справді прихована (через
hidden) коли згорнута? - Чи немає глобального фокус-трепа, що блокує табування в панель?
Якщо ARIA-сигнал бреше, користувачі скрінрідерів повідомлятимуть про «випадкову» поведінку. Це не випадково; ви транслюєте неправильний стан.
Четверте: нестабільність через продуктивність
- Чи відкриття меню не плавиться через важкі box-shadow, blur-фільтри або забагато зображень?
- Чи не викликаєте layout thrash анімацією height або вимірюванням DOM у циклі?
Якщо відкриття відчувається повільно, зменшіть витрати на paint і уникайте синхронних примусових макетів. Мега-меню має відкриватися як щось, що соромиться займати ваш час.
Поширені помилки: симптом → причина → виправлення
Ось баги, що постійно з’являються, тому що команди копіюють шаблони, не розуміючи, яку проблему вони вирішували.
Використовуйте цей розділ як діагностичну карту.
1) Меню закривається при переміщенні вказівника до панелі
Симптом: Hover відкриває, але панель зникає, коли ви намагаєтесь перейти в неї.
Причина: Є розрив між триггером і панеллю; стан hover втрачається.
Виправлення: Додайте «місток» з відступом або псевдоелементом (::before) на панелі; розташуйте панель щільно до зони триггера.
2) Користувачі клавіатури не можуть дістатися до посилань панелі
Симптом: Tab відкриває панель, але як тільки фокус переходить, панель закривається.
Причина: Лише :hover відкриває панель; немає підтримки :focus-within.
Виправлення: Відкривайте панель на li:focus-within, а не лише при hover. Переконайтеся, що панель — нащадок контейнера з focus-within.
3) На мобільних пристроях тап відкриває, а потім одразу закриває
Симптом: Тап перемикає, але стан негайно повертається.
Причина: Обробник «клік поза елементом» спрацьовує, бо подія бульбашиться; або використовується document click listener без винятків.
Виправлення: Перестаньте трактувати все як «поза». Перевірте containment через event.target; використовуйте pointerdown capture обережно; ігноруйте клік триггера, що відкрив панель.
4) Меню з’являється за хедером або героєм
Симптом: Панель відкрита (в DOM), але непомітна або частково прихована.
Причина: Stacking context або обрізання: предок має transform або overflow:hidden.
Виправлення: Видаліть проблемну властивість або відрендеріть панель у портал в корінь документа. Потім встановіть розумну масштабну шкалу z-index.
5) Скринрідер оголошує «згорнуто», коли воно відкрите
Симптом: Візуальний стан і стан для допоміжних технологій різняться.
Причина: aria-expanded не оновлюється, або панель показується через CSS без оновлення ARIA.
Виправлення: Якщо дія користувача переключає видимість, оновлюйте aria-expanded в тому ж кодовому шляху. Віддавайте перевагу явному стану для явних перемикань.
6) Тапування призводить до фокусу на невидимих посиланнях
Симптом: Фокус зникає; користувач клавіатури губиться.
Причина: Панель візуально прихована через opacity/transform, але все ще в порядку табуляції.
Виправлення: Використовуйте hidden (або умовний рендер) коли панель згорнута. Якщо треба анімація, анімуйте зі стратегією, що не залишає елемент фокусованим при закритті.
7) Макет стрибає при відкритті меню (CLS)
Симптом: Контент опускається вниз, коли з’являється панель.
Причина: Панель у нормальному потоці (не overlay) на десктопі; відкриття змінює висоту хедера.
Виправлення: Абсолютно позиціонуйте панель на десктопі або цілеспрямовано резервуйте простір у стабільній області хедера. Не дозволяйте hover перетасувати сторінку.
8) Мега-меню стає податком на продуктивність
Симптом: Відкриття лагує; падіння FPS; сумна батарея.
Причина: Важкий blur/backdrop-filter, забагато тіней, великі зображення або дорогі операції під час відкриття.
Виправлення: Зменшіть ефекти, задайте розміри зображень заздалегідь, уникайте розмиття. Якщо анімуєте — анімуйте opacity/transform. Вимірюйте на середньому мобільному.
Контрольні списки / план крок за кроком
Це план, який я віддав би команді, що має відправити мега-меню в продакшен без проведення наступного кварталу у триажі багів.
Він нудний. Саме тому працює.
План збірки крок за кроком (робіть у цьому порядку)
- Визначте IA. Ідентифікуйте категорії верхнього рівня і групи посилань. Обмежте кількість елементів у групі; зробіть сторінки «Всі…» для решти.
- Напишіть семантичний HTML перш ніж стилізувати. Nav → ul/li → links. Додавайте кнопки розкриття лише там, де є панель.
- Реалізуйте правила відкриття для десктопу в CSS. Використовуйте
:focus-withinі hover, обмежений можливістю вказівника. - Реалізуйте макет панелі за допомогою Grid. Будуйте групи як елементи сітки, щоб заголовки не відокремлювалися від своїх посилань.
- Додайте мінімальний JS для явних перемикачів. Керуйте
aria-expanded,hidden, Escape і кліками поза елементом (якщо оверлей). - Рішення для мобільного макета. Вбудований акордеон або ящик. Не намагайтеся зберегти десктопний hover UX.
- Правила управління фокусом. Для ящиків переміщуйте фокус всередину і назад; переконайтеся, що фон не фокусований, коли це потрібно.
- Перевірки доступності в CI. Axe або eslint-правила, що блокують мерджі при очевидних регресіях.
- Перевірки продуктивності. Уникайте фільтрів розмиття, зменшуйте вартість paint, слідкуйте за зростанням JS-бандла.
- Тестування різними режимами вводу. Клавіатура + миша + торкання. Також тестуйте з зумом 200% і збільшеним розміром шрифту.
Перевірка перед мерджем (швидко, але суворо)
- Клавіатурою можна дістатися до кожного посилання в кожній панелі.
- Індикатор фокусу видимий і послідовний.
aria-expandedкоректно оновлюється при перемиканні.- Панелі не фокусуються при закритті (
hiddenабо не рендеряться). - На дотику поведінка детермінована (немає емульованого hover).
- Escape закриває відкритий стан; фокус повертається до триггера.
- Немає зсуву макета при відкритті на десктопній ширині.
- Панель не обрізається overflow;
z-indexрозумний і документований.
Після розгортання (бо продакшен — місце істини)
- Моніторити клієнтські помилки навколо коду перемикання навігації.
- Перегляд сесій або аналітичні події на предмет повторюваних відкриттів/закриттів (ознака неправильних тапів або проблем з хіт-зонами).
- Слідкувати за метриками продуктивності (INP, CLS) після релізу.
- Перевірити, що cookie-банери, алерти та A/B тести не накладають навігацію дивним чином.
Три корпоративні історії з польових умов
Міні-історія №1: Інцидент через неправильне припущення
Компанія B2B SaaS випустила перероблений хедер з мега-меню. Дизайнер тестував на MacBook з мишею. Інженер тестував у режимі responsive Chrome DevTools.
Обидва були впевнені. Реліз вийшов у вівторок, бо, виявляється, всі хотіли чогось навчитися.
Через кілька годин служба підтримки отримала патерн: мобільні користувачі не могли перейти на сторінки з цінами. Меню «відкривалося», потім закривалося раніше, ніж вони могли щось натиснути.
Product подумав, що це «баг Safari». Інженери думали «проблема з хіт-таргетами». Маркетинг думав «користувачі дурні». Лише одне з припущень було виправним.
Корінь проблеми — неправильне припущення: «правила hover не важливі на мобільних». У них був CSS, що відкривав панелі на :hover і закривав на mouseout.
На iOS перший тап викликав стан, схожий на hover, а потім документальний обробник кліку трактував наступну взаємодію як клік поза елементом і закривав панель.
Компонент фактично боровся сам із собою.
Виправлення було непоказним: обмежити hover через @media (hover:hover) and (pointer:fine) і використовувати явний стан перемикача лише на мобільних.
Також поправили обробник кліків поза елементом, щоб ігнорувати взаємодію, що відкрила панель.
Постмортем теж був нудним: «Ми будемо тестувати на одному реальному телефоні перед релізом змін навігації.» Це правило залишилося, бо його легко виконати і воно зекономило час усім.
Міні-історія №2: Оптимізація, що дала зворотний ефект
Інша організація мала мега-меню з зображеннями та промо-картками всередині панелі. Хтось помітив, що відкриття меню повільне на старих ноутбуках.
Команда зробила те, що роблять команди: оптимізувала. Вирішили lazy-load все всередині панелі лише при відкритті, включно з групами посилань, які підвантажуються з CMS.
На папері — чисте рішення: не рендерити те, що не видно. На практиці панель отримала мережеву залежність у шляху першої взаємодії.
Перше відкриття займало помітну мить; іноді панель відкривалася порожньою, а потім заповнювалась. Користувачі клавіатури табували в нікуди і застрягали.
Потім з’явився тонкий фейл: endpoint CMS іноді був повільним. Не повністю недоступним, але достатньо повільним. Меню «працювало», але стало непередбачуваним.
Клієнти описували це як «навігація нестабільна». Нестабільно — це ввічливо.
Вони відкотили підхід fetch-on-open і пішли простішим шляхом: відправляти базові групи посилань у початковому HTML/JSON, а lazy-load робити лише для декоративних зображень.
Панель знову відкривалася миттєво, і сприйнята продуктивність покращилась більше, ніж первісна оптимізація.
Урок: оптимізувати, додаючи рантайм-залежності в критичні UI-шляхи — все одно що кешувати паролі в відкритому вигляді, бо так швидше. Так, швидше. Ні, не можна.
Міні-історія №3: Нудна, але правильна практика, що врятувала день
Великий корпоративний сайт проводив десятки експериментів. Хедером «володіла» платформа, але різні growth-команди інжектували банери, липкі промо
і іноді чат-віджет, що наполегливо жив у верхньому правому куті як агресивна кімнатна рослина.
Команда платформи підтримувала шкалу z-index в CSS і вимагала її дотримання під час коду-рев’ю. Вони також мали правило: «Все, що накладається на хедер, має рендеритися в
окремому overlay root, а не всередині випадкових секцій сторінки». Це звучало педантично — поки не стало рятувальним.
В одну п’ятницю growth-експеримент додав герой-блок з легким transform-анімацією. Це створило stacking context. На багатьох сторінках мега-панель з’являлася за героєм, роблячи навігацію зламаною.
Growth хотів «просто поставити z-index мільйон».
Команда платформи не погодилася з хаосом. Оскільки overlay root вже був стандартом, мега-панель рендерилась поза трансформованим героєм.
Виправлення було одно-рядковим: прибрати непотрібний transform у CSS експерименту. Ніякої гонитви за z-index. Ніякого інциденту на вихідних.
Нудна практика — документовані правила шарування і окремий overlay root — врятувала їх від класу багів, що інакше ніколи повністю не вмирають.
Питання та відповіді
1) Чи можна побудувати мега-меню лише на CSS?
На десктопі — здебільшого так: наведення і :focus-within покривають багато випадків. На мобільних все одно потрібен JavaScript для управління тап-перемикачами і ARIA-станом.
«Лише CSS» — це гарне демо, але ненадійна вимога для продукту.
2) Чи має елемент верхнього рівня бути посиланням чи кнопкою?
Якщо він навігує — це посилання. Якщо перемикає видимість — це кнопка. Якщо потрібні обидва — розділіть їх: мітка посилання навігує; сусідня кнопка перемикає.
Змішана поведінка на одному контролі — місце, де UX іде на аудит.
3) Чи потрібен role="menu" для доступності?
Ні. Для навігації сайту використовуйте нативні елементи і ARIA для стану розкриття (aria-expanded). Додавання ролей меню змінює очікуваний клавіатурний інтерфейс
(стрілки, ролі menuitem). Якщо ви не реалізуєте повний патерн — не починайте.
4) Як не допустити обрізання панелі під хедером?
Шукайте overflow:hidden на обгортках хедера і stacking contexts від transform. Якщо не можна їх прибрати, відрендерте панель у вищий контейнер (портал/overlay root)
і позиціонуйте відносно триггера.
5) Як найкраще закривати меню при кліку поза ним?
Робіть це лише для оверлеїв/ящиків. Для десктопних hover-панелей правила фокусу/наведення зазвичай самі закривають. Якщо реалізуєте outside click,
перевіряйте panel.contains(event.target) і trigger.contains(event.target) перед закриттям, і будьте обережні з порядком подій.
6) Скільки колонок має бути в мега-панелі?
Почніть з 2–3 колонок на десктопі. Більше колон збільшує вартість сканування і зменшує розмір заголовків. Використовуйте Grid з адаптивним колапсом; нехай контент переноситься акуратно.
Якщо вам потрібно 6 колонок — ймовірно, потрібна краща групування.
7) Чому моє hover-меню «мерехтить» лише по діагоналі?
Бо вказівник виходить з триггера до того, як зайде в панель. Додайте місток (::before), зменшіть розрив або зробіть триггер вищим.
Таймери можуть змазати проблему, але часто створюють нові.
8) Чи треба анімувати відкриття?
Якщо можна відкривати миттєво — робіть так. Якщо анімуєте, тримайте коротко, уникайте анімацій макета і дотримуйтесь reduced motion.
Навігація має відчуватися чутливою, а не театралізованою.
9) Як надійно тестувати це в CI?
Використовуйте Playwright для поведінкових тестів (порядок табуляції, Escape закриває, тап на мобільному перемикає). Використовуйте axe для порушень доступності.
Додайте пару візуальних регресійних знімків для відкритої панелі на ключових брейкпоінтах. Зберігайте тести стабільними, уникаючи таймінгових хаків.
10) Чи достатньо inert для доступності ящика?
Це потужний інструмент, але потрібно ще керувати фокусом при відкритті/закритті і забезпечити роботу Escape.
Також перевірте підтримку браузерів у вашому таргеті; інакше використовуйте добре перевірений фокус-треп.
Висновок: наступні кроки, які дійсно відправляються в продакшен
Мега-меню — не дизайнерська прикраса. Це інфраструктура продакшена для навігації. Ставтеся до нього так, як до балансувальника навантаження: передбачувана поведінка, розумні значення за замовчуванням,
вимірювана продуктивність і нудна правильність.
Наступні кроки:
- Напишіть семантичний HTML і вирішіть для кожного елемента верхнього рівня: посилання чи кнопка.
- Реалізуйте десктопну поведінку з
:focus-withinі hover, обмежений можливістю вказівника. - Виберіть мобільну модель (вбудований акордеон або ящик) і реалізуйте явний стан з правильним
aria-expanded. - Додайте щонайменше три тести Playwright: потік табуляції клавіатурою, закриття Escape та мобільний тап-перемикач.
- Запустіть Lighthouse і axe, і підключіть один з них у CI, щоб не допустити регресій у п’ятницю.
А потім зробіть найменш популярний крок у інженерії: протестуйте на реальному телефоні. Навігація або працюватиме, або ні. Реальність не переймається вашою бібліотекою компонентів.