Mega menu krok po kroku: HTML, CSS i JavaScript z dostępnością
Jak zrobić mega menu na stronie: kod HTML, CSS i JavaScript do skopiowania, obsługa klawiatury i czytników ekranu, wersja na telefon i najczęstsze błędy. Z działającym demo.
Mega menu to duży panel, który rozwija się z górnego menu i pokazuje naraz kilka grup linków, często z krótkimi opisami. Znasz je ze sklepów internetowych i stron firm z rozbudowaną ofertą. Dobrze zrobione skraca klientowi drogę do konkretnej usługi. Źle zrobione zamyka się, zanim kursor do niego dojedzie, nie działa na telefonie i nie da się go obsłużyć klawiaturą.
W tym poradniku zbudujemy mega menu od zera: HTML, CSS i około 30 linii JavaScriptu, bez żadnej biblioteki. Kod sprawdziłem w przeglądarce, a działające demo otworzysz tutaj. Ten sam wzorzec działa w moim własnym menu: przycisk „Darmowe narzędzia” u góry tej strony.
Kiedy mega menu ma sens
Mega menu przydaje się, gdy:
- w jednej części oferty masz więcej niż 7–8 podstron, na przykład kilka rodzajów usług w kilku grupach,
- oferta naturalnie dzieli się na kategorie (sklep, firma budowlana, biuro rachunkowe z usługami dla różnych klientów),
- chcesz pokazać przy linkach krótki opis albo jedno wyróżnione wezwanie do działania.
Jeśli cała strona ma 5–6 podstron, zwykłe menu wystarczy. Mega menu z trzema linkami wygląda pusto i tylko wydłuża kod.
Jak ma działać dobre mega menu
Zanim zaczniesz pisać kod, ustal zasady. Każda z nich ma swój powód:
- Otwiera się po kliknięciu, a z klawiatury klawiszem Enter lub spacją. Samo najechanie myszką nie działa na telefonie.
- Zamyka się po kliknięciu obok, klawiszem Escape i wtedy, gdy klawiszem Tab przejdziesz dalej.
- Czytnik ekranu wie, czy panel jest rozwinięty dzięki atrybutowi
aria-expanded. - Linki to zwykłe znaczniki
<a>w HTML, więc Google widzi wszystkie podstrony. - Między przyciskiem a panelem nie ma szczeliny, w której kursor „gubi” menu.
- Na telefonie panel zamienia się w listę rozwijaną pod przyciskiem „Menu”.
- Grupy mają nagłówki, a cały panel ma najwyżej jedno wyróżnione wezwanie do działania.

Krok 1: HTML
Każda rozwijana pozycja to przycisk i panel. Przycisk, a nie link, bo niczego nie otwiera pod nowym adresem, tylko pokazuje panel. Atrybut aria-controls wskazuje panel, a hidden ukrywa go na starcie:
<header class="top">
<div class="top__in">
<a class="logo" href="/">Pomost</a>
<button class="burger" type="button" aria-expanded="false" aria-controls="menu">Menu</button>
<nav class="nav" id="menu" aria-label="Menu główne">
<ul class="nav__list">
<li>
<button class="nav__toggle" type="button" aria-expanded="false" aria-controls="mm-oferta">Oferta</button>
<div class="mega" id="mm-oferta" hidden>
<div class="mega__in">
<div>
<p class="mega__title" id="mm-tarasy">Tarasy</p>
<ul aria-labelledby="mm-tarasy">
<li><a href="/tarasy-kompozytowe">Tarasy kompozytowe<small>Bez malowania, na lata</small></a></li>
<li><a href="/tarasy-drewniane">Tarasy drewniane<small>Modrzew, sosna, egzotyki</small></a></li>
</ul>
</div>
<!-- kolejne kolumny: Ogrody, Dodatki -->
<div class="mega__promo">
<b>Bezpłatny pomiar</b>
<p>Przyjedziemy, zmierzymy i w 2 dni prześlemy wycenę.</p>
<a href="/kontakt">Umów pomiar</a>
</div>
</div>
</div>
</li>
<li><a href="/realizacje">Realizacje</a></li>
<li><a href="/kontakt">Kontakt</a></li>
</ul>
</nav>
</div>
</header>
Nagłówek grupy jest zwykłym akapitem, a lista łączy się z nim przez aria-labelledby. Czytnik ekranu ogłasza wtedy „Tarasy, lista, 3 pozycje”, a w strukturze nagłówków strony nie pojawia się kilkanaście dodatkowych H2.
Często spotykam w takich menu role="menu" i role="menuitem". To błąd: te role są przeznaczone dla menu aplikacji (jak „Plik, Edycja” w edytorze) i obiecują obsługę strzałkami, której zwykła strona nie ma. Do nawigacji wytyczne WAI-ARIA Authoring Practices polecają właśnie wzorzec „disclosure”, czyli przycisk, który pokazuje i ukrywa zawartość.
Krok 2: CSS
Najważniejsze są trzy rzeczy: panel przylega do nagłówka bez szczeliny, wygląd przycisku zależy od aria-expanded, a fokus z klawiatury jest dobrze widoczny:
.top { position: relative; background: #fff; }
.nav__list { display: flex; gap: 4px; margin: 0; padding: 0; list-style: none; }
.nav__toggle { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px;
border: 0; border-radius: 10px; background: none; font: inherit; cursor: pointer; }
/* strzałka obraca się, gdy panel jest otwarty: stan czytany prosto z aria-expanded */
.nav__toggle::after { content: ""; width: 7px; height: 7px; border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform .2s; }
.nav__toggle[aria-expanded="true"]::after { transform: translateY(1px) rotate(-135deg); }
/* panel na całą szerokość, zaczyna się dokładnie pod nagłówkiem (top: 100%, bez marginesu) */
.mega { position: absolute; left: 0; right: 0; top: 100%; z-index: 10; background: #fff;
box-shadow: 0 24px 40px -24px rgba(17, 21, 28, .25); }
.mega__in { max-width: 1120px; margin: 0 auto; padding: 28px 20px 32px;
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 1.2fr); gap: 32px; }
.mega ul { margin: 0; padding: 0; list-style: none; }
.mega ul a { display: block; padding: 8px 10px; border-radius: 10px; text-decoration: none; }
.mega ul small { display: block; color: #5b6472; font-size: 13.5px; }
:focus-visible { outline: 3px solid #2f5bff; outline-offset: 2px; }
/* krótkie wejście panelu, ale nie dla osób, które wyłączyły animacje w systemie */
@media (prefers-reduced-motion: no-preference) {
.mega:not([hidden]) { animation: wjazd .18s ease-out; }
}
@keyframes wjazd { from { opacity: 0; transform: translateY(-6px); } }
Zwróć uwagę na minmax(0, 1fr) w siatce. Bez tego długie słowo w kolumnie potrafi rozepchnąć panel szerzej niż ekran.
Krok 3: JavaScript
Skrypt robi cztery rzeczy: otwiera i zamyka panel po kliknięciu, zamyka inne panele, obsługuje Escape i zamyka menu, gdy klikniesz obok albo przejdziesz klawiszem Tab poza menu:
const nav = document.querySelector('.nav');
const toggles = [...nav.querySelectorAll('.nav__toggle')];
function setOpen(btn, open) {
btn.setAttribute('aria-expanded', String(open));
document.getElementById(btn.getAttribute('aria-controls')).hidden = !open;
}
function closeAll(except) {
toggles.forEach((b) => { if (b !== except) setOpen(b, false); });
}
toggles.forEach((btn) => btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') !== 'true';
closeAll(btn);
setOpen(btn, open);
}));
// Escape zamyka otwarty panel i wraca do jego przycisku
nav.addEventListener('keydown', (e) => {
const btn = toggles.find((b) => b.getAttribute('aria-expanded') === 'true');
if (e.key === 'Escape' && btn) { setOpen(btn, false); btn.focus(); }
});
// kliknięcie poza menu i przejście fokusem na element poza menu zamykają panel
document.addEventListener('click', (e) => { if (!nav.contains(e.target)) closeAll(); });
document.addEventListener('focusin', (e) => { if (!nav.contains(e.target)) closeAll(); });
Stan menu jest zapisany w jednym miejscu: w atrybutach aria-expanded i hidden. CSS czyta go do obracania strzałki, czytnik ekranu do ogłaszania „rozwinięty” i „zwinięty”, a skrypt do decyzji, co zamknąć. Nie ma osobnych klas typu is-open, które mogłyby się rozjechać z tym, co słyszy osoba niewidoma.
Ciekawostka z testów: pierwsza wersja zamykała menu w zdarzeniu focusout. Działało z klawiatury, ale panel znikał po kliknięciu w jego tło, bo przeglądarka zdejmuje wtedy fokus z przycisku. Zdarzenie focusin na całym dokumencie reaguje tylko wtedy, gdy fokus faktycznie trafia na inny element, więc nie ma tego problemu.
Krok 4: wersja na telefon
Na telefonie nie ma miejsca na cztery kolumny. Ten sam HTML zamieniamy w listę rozwijaną: nawigacja chowa się za przyciskiem „Menu”, a panele układają się jeden pod drugim:
.burger { display: none; }
@media (max-width: 860px) {
.burger { display: inline-flex; margin-left: auto; padding: 10px 14px;
border: 1px solid #e3e6eb; border-radius: 10px; background: none; font: inherit; }
.nav { display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
max-height: calc(100dvh - 68px); overflow-y: auto; background: #fff; }
.nav.is-open { display: block; }
.nav__list { flex-direction: column; padding: 8px 12px 16px; }
.nav__toggle { width: 100%; justify-content: space-between; padding: 14px 12px; }
.mega { position: static; box-shadow: none; }
.mega__in { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 8px 12px 16px; }
}
const burger = document.querySelector('.burger');
burger.addEventListener('click', () => {
const open = burger.getAttribute('aria-expanded') !== 'true';
burger.setAttribute('aria-expanded', String(open));
nav.classList.toggle('is-open', open);
});
max-height z jednostką dvh i overflow-y: auto sprawiają, że długie menu da się przewinąć na małym ekranie, a pasek adresu przeglądarki w telefonie go nie zasłania.

Jak przetestować menu w 5 minut
Zanim opublikujesz menu, sprawdź je tak, jak będą go używać ludzie:
- Klawiatura: klawiszem Tab dojdź do „Oferta”, Enter otwiera panel, kolejne Taby przechodzą po linkach, Escape zamyka panel i wraca na przycisk. Tab za ostatnim linkiem zamyka panel.
- Myszka: przejedź kursorem z przycisku do panelu ukosem i kliknij w puste miejsce panelu. Panel ma zostać otwarty.
- Czytnik ekranu: w NVDA (Windows) albo VoiceOver (Mac, iPhone) przycisk powinien brzmieć „Oferta, przycisk, zwinięty”, a po otwarciu „rozwinięty”.
- Telefon i powiększenie: przy szerokości 320 px i powiększeniu 200% nic nie może wychodzić poza ekran ani wymuszać przewijania w bok.
- Lighthouse w Chrome (zakładka Dostępność) wyłapie brakujące nazwy przycisków i zbyt słaby kontrast.
Ogólny stan strony, w tym dostępność, SEO i szybkość, sprawdzisz w 30 sekund w moim narzędziu Zbadaj stronę.
Najczęstsze błędy w mega menu
- Menu tylko na najechanie. Na telefonie nie da się go otworzyć, a na komputerze otwiera się przypadkiem.
- Szczelina między przyciskiem a panelem. Wystarczy
margin-top: 8px, żeby kursor w drodze do panelu wypadł z menu i wszystko się zamknęło. <a href="#">zamiast przycisku. Strona przeskakuje na górę, a czytnik ekranu ogłasza link, który nigdzie nie prowadzi.role="menu"bez obsługi strzałek. Obietnica, której strona nie spełnia, jest gorsza niż brak ARIA.- Linki doczytywane skryptem dopiero po najechaniu. Google może ich nie zobaczyć.
- Za dużo linków. Panel ze 120 pozycjami to katalog, nie nawigacja. Klient szuka 2–3 sekundy i jeśli nie znajdzie, wraca do Google.
- Panel szerszy niż ekran na telefonie. Klasyczny objaw: strona da się przesunąć w bok o kilka pikseli.
A co z WordPressem?
W WordPressie mega menu zwykle robi motyw albo wtyczka. Zanim ją zostawisz, przejdź tę samą listę testów: wiele wtyczek otwiera menu tylko na najechanie, nie ustawia aria-expanded i dokłada kilkadziesiąt kilobajtów skryptów na każdej podstronie. Czasem prościej i szybciej jest dopisać własne menu na bazie kodu z tego poradnika.
Podsumowanie
Dobre mega menu to przycisk z aria-expanded, panel z hidden, zwykłe linki w HTML i trzy sposoby zamykania: kliknięcie obok, Escape i Tab. Na telefonie ten sam kod działa jako lista rozwijana. Pełny kod z tego artykułu jest w demo: otwórz je, sprawdź myszką i klawiaturą, a potem podejrzyj źródło strony.
Jeśli Twoja strona ma rozbudowaną ofertę, a menu nie nadąża, napisz do mnie. Przebuduję nawigację w obecnej stronie albo zaprojektuję nową stronę firmową. Odpowiadam w 24 godziny, a nowi klienci dostają 20% rabatu na pierwsze zlecenie.
Najczęstsze pytania
Nie, jeśli linki są w zwykłym kodzie HTML strony, a nie doczytywane dopiero po najechaniu myszką. Google widzi wtedy wszystkie podstrony. Nie przesadzaj jednak z liczbą linków: kilkanaście dobrze nazwanych pozycji pomaga, a kilkaset rozmywa sygnał, które podstrony są najważniejsze.
Lepiej po kliknięciu. Najechanie nie działa na ekranach dotykowych, a na komputerze menu otwiera się przypadkiem, gdy kursor tylko przejeżdża przez nagłówek. Jeśli zależy Ci na najechaniu, dodaj opóźnienie około 200 ms i zostaw kliknięcie jako główny sposób otwierania.
Rola menu jest przeznaczona dla menu aplikacji, takich jak „Plik, Edycja” w edytorze tekstu, i wymaga obsługi strzałkami. Do nawigacji strony wytyczne WAI-ARIA polecają prostszy wzorzec: przycisk z aria-expanded, który pokazuje i ukrywa listę zwykłych linków.
Dobra reguła to 3–4 kolumny i 3–8 linków w każdej. Jeśli potrzebujesz więcej, to znak, że warto uporządkować ofertę albo dodać stronę kategorii, do której menu prowadzi.
Usługi związane z tematem
Masz podobny projekt?
Opisz, czego potrzebujesz, a w 24 godziny odeślę wycenę. Nowi klienci dostają 20% rabatu na pierwsze zlecenie.


