Бургер-меню состоит из настоящей button, списка ссылок и одного класса is-open. На широком экране ссылки видны в строку, на узком они открываются по клику, закрываются по Escape и сообщают состояние через aria-expanded.
Три полоски сами по себе не являются меню. Интерфейс должен работать с клавиатуры, показывать фокус, не прятать ссылки от скринридера и не оставаться открытым после перехода на desktop. Поэтому мы сначала соберём семантику, затем анимацию.
button знает id списка через aria-controls.
JavaScript меняет is-open и aria-expanded одновременно.
Escape, клик по ссылке и переход на desktop возвращают базовое состояние.
Из какой HTML-разметки собирается меню
Кнопка и навигация должны быть соседями в header, но не обязаны лежать внутри друг друга. Для полосок используйте три span с aria-hidden, а понятное имя кнопки задайте через aria-label. На desktop кнопка display:none, а список остаётся flex.
| Элемент | Атрибут | Зачем |
|---|---|---|
| button | aria-expanded | Сообщает открытое состояние |
| button | aria-controls | Связывает с nav |
| nav | id | Даёт цель aria-controls |
| span | aria-hidden | Не засоряет имя кнопки |
После этого шага у каждой части есть одна роль. Интерфейс собирает действие, логика меняет состояние, а вывод показывает признак успеха. Это проще отлаживать, чем один большой обработчик.

Видимое и доступное состояние меняются вместе. Escape проходит через ту же функцию
Пишем CSS и JavaScript для открытия
Анимируйте transform и opacity, а не height от 0 до auto. Браузер может плавно менять числовую max-height, но для короткого выпадающего меню проще двигать готовую панель. При prefers-reduced-motion уберите transition, чтобы не навязывать движение.
<button class="menu-button" aria-expanded="false"
aria-controls="main-menu" aria-label="Открыть меню">
<span aria-hidden="true"></span><span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
<nav class="menu" id="main-menu">
<a href="#home">Главная</a><a href="#projects">Проекты</a>
<a href="#contact">Контакт</a>
</nav>
<style>
.menu-button{display:none;width:48px;height:48px;border:0;background:transparent}
.menu-button span{display:block;width:28px;height:3px;margin:5px auto;background:#151c4e;transition:.2s}
.menu{display:flex;gap:24px}.menu a{padding:12px;color:inherit;text-decoration:none}
@media(max-width:720px){
.menu-button{display:block}.menu{position:absolute;inset:72px 16px auto;display:grid;
padding:16px;background:white;border-radius:18px;box-shadow:0 18px 50px #14205a22;
opacity:0;transform:translateY(-10px);pointer-events:none;transition:.2s}
.menu.is-open{opacity:1;transform:none;pointer-events:auto}
}
@media(prefers-reduced-motion:reduce){.menu,.menu-button span{transition:none}}
</style>
<script>
const button=document.querySelector('.menu-button');
const menu=document.querySelector('#main-menu');
function setOpen(open){
menu.classList.toggle('is-open',open);
button.setAttribute('aria-expanded',String(open));
button.setAttribute('aria-label',open?'Закрыть меню':'Открыть меню');
}
button.addEventListener('click',()=>setOpen(button.getAttribute('aria-expanded')!=='true'));
menu.addEventListener('click',e=>{if(e.target.closest('a'))setOpen(false)});
document.addEventListener('keydown',e=>{if(e.key==='Escape'){setOpen(false);button.focus()}});
matchMedia('(min-width:721px)').addEventListener('change',e=>{if(e.matches)setOpen(false)});
</script>Кнопка является button и доступна с клавиатуры. aria-expanded меняется вместе с классом. Escape закрывает меню и возвращает фокус. Переход на desktop сбрасывает мобильное состояние.

Семантика экономит JavaScript. Сравнение показывает, какой вариант легче поддерживать и проверять.
Как aria-expanded и Escape делают меню понятным
aria-expanded не открывает меню само, но передаёт его состояние в дерево доступности. Скринридер может сказать, что кнопка свёрнута или развёрнута. aria-controls связывает кнопку с nav по id. Эти атрибуты нужно менять в той же функции, где меняется CSS-класс.
| Действие | is-open | aria-expanded |
|---|---|---|
| Загрузка | Нет | false |
| Клик по button | Да | true |
| Escape | Нет | false |
| Переход на desktop | Нет | false |
Сохраняйте в одном месте то, что может измениться. Если одно и то же значение скопировано в три функции, после первой правки они начнут расходиться. Один объект состояния и одна функция отрисовки снимают эту проблему.
Практика: проверяем мышь, клавиатуру и ширину
Тесируйте меню на ширине 320, 768 и 1024 пикселя. На 320 ссылки не должны выходить за края, на 768 не должен появляться промежуточный сломанный режим, а на 1024 все ссылки должны быть видны без кнопки. Пройдите элементы Tab и слушайте, как меняется состояние кнопки.
- Сузьте окно до 390 пикселей и откройте меню.
- Перейдите к кнопке Tab и нажмите Enter.
- Нажмите Escape и убедитесь, что фокус вернулся.
- Откройте меню и кликните по ссылке.
- Не закрывая меню, растяните окно шире 721 пикселя.
- Включите reduced motion в системе и сверьте отсутствие анимации.
На desktop все ссылки видны без кнопки. На mobile меню открывается мышью, касанием и Enter. Escape закрывает и возвращает фокус. aria-expanded всегда совпадает с видимым состоянием.

Проверьте до полировки анимации. Пройдите все четыре сценария перед публикацией проекта.
Доводим бургер-меню до готового вида
Не превращайте бургер в ящик для всего. В мобильном меню оставьте главную навигацию, а вторичные ссылки перенесите в footer. Кнопка должна иметь площадь не меньше 44 на 44 пикселя и заметный focus-visible. Анимация полосок в крестик может быть добавлена после проверки всех состояний.
Такой элемент не получит клавиатуру и семантику бесплатно.
Скринридеру нужно знать expanded и controls.
Клавиатурой меню легко открыть и неудобно закрыть.
На desktop класс нужно сбрасывать.
Что получится в итоге

В итоге на узком экране появляется крупная кнопка, а ссылки выезжают в чистой карточке и остаются доступными с клавиатуры.
Почему нужен button?
Он изначально доступен с клавиатуры и сообщает вспомогательным технологиям, что это действие.
Зачем aria-expanded?
Атрибут показывает, свёрнут или развёрнут связанный блок.
Когда сбрасывать меню?
По Escape, клику по ссылке и переходу на desktop-ширину.
Как понять, что проект готов?
Он повторяет целевой сценарий несколько раз подряд, понятно реагирует на ошибочный ввод и не теряет состояние при обычных действиях.
Можно ли копировать код целиком?
Можно как стартовую точку, но сначала замените значения из примера, затем запустите проект по шагам. Если вы не можете объяснить одну строку, её лучше разобрать до следующего шага.
Сетку и медиа-запросы можно закрепить в курсе CSS, а события в курсе JavaScript. Общие правила точек перелома есть в гайде по адаптиву.
Если клики не срабатывают, сверьте подключение со статьё про JavaScript в HTML. В Кодике можно сузить превью до ширины телефона и сразу проверить меню.