Бургер меню html css js я собираю в каждом втором проекте, и каждый раз уходит на это не больше получаса, если не тянуть jQuery и готовые плагины. Нужны одна кнопка, один блок навигации, пара десятков строк CSS и короткий скрипт, который переключает класс и обновляет атрибут. Ниже весь путь: разметка, стили, скрипт, доступность и ошибки, на которых я чаще всего спотыкался сам и вижу их в чужих проектах.
Разметка: кнопка и навигация
Первое решение, от которого зависит всё остальное: бургер это кнопка, а не div и не ссылка. Элемент button сам получает фокус, срабатывает по Enter и пробелу и читается скринридером как кнопка. Если сделать иконку из div, придётся вручную добавлять роль, tabindex и обработку клавиш, и всё равно получится хуже.
Три полоски иконки я рисую тремя span, без картинок и иконочных шрифтов. Так иконка красится одним цветом, не требует запроса к серверу и легко анимируется в крестик.
<header class="header">
<a class="logo" href="/">Логотип</a>
<button class="burger" type="button"
aria-label="Открыть меню"
aria-expanded="false"
aria-controls="nav">
<span class="burger__line"></span>
<span class="burger__line"></span>
<span class="burger__line"></span>
</button>
<nav class="nav" id="nav">
<ul>
<li><a href="/catalog/">Каталог</a></li>
<li><a href="/delivery/">Доставка</a></li>
<li><a href="/about/">О компании</a></li>
<li><a href="/contacts/">Контакты</a></li>
</ul>
</nav>
</header>
Атрибут type="button" нужен, чтобы кнопка внутри формы случайно не отправила её. aria-expanded сообщает вспомогательным технологиям, раскрыто меню или нет, а aria-controls связывает кнопку с блоком навигации по id. Сам список ссылок остаётся обычным ul внутри nav: на десктопе он выводится в строку, на мобильном превращается в колонку, а разметка одна и та же. Поисковик видит все ссылки в разметке в любом случае. А вот пользователю без JavaScript мобильное меню не раскрыть: снять max-height: 0 и visibility: hidden может только класс is-open, который навешивает скрипт, так что для сценария без JS нужен отдельный фоллбэк.
Стили: адаптивная навигация и анимация иконки
Порог переключения я беру 768px: ниже него меню складывается за кнопку. Это общепринятая граница планшета, но на практике я смотрю на сам макет: если пункты перестали помещаться в одну строку раньше, порог поднимаю. Подбирать его лучше по контенту, а не по списку устройств.
Десктопная версия по умолчанию
Сначала пишу обычную горизонтальную шапку, кнопку прячу. Так на широком экране не нужно ничего отменять, а мобильные правила собираются в одном медиазапросе в конце.
Мобильное выпадающее меню
Выезжающий блок я позиционирую абсолютно от шапки (top: 100%), скрываю через max-height: 0 и visibility: hidden. Второе свойство важно: без него закрытые ссылки остаются в порядке табуляции, и пользователь клавиатуры бродит по невидимому меню. А visibility вместе с переходом по времени даёт плавное закрытие без этого побочного эффекта.
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: #fff;
}
.nav ul {
display: flex;
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.nav a {
color: #222;
text-decoration: none;
}
.burger {
display: none;
position: relative;
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.burger__line {
position: absolute;
left: 10px;
right: 10px;
height: 2px;
background: #222;
transition: transform 0.25s, opacity 0.2s;
}
.burger__line:nth-child(1) { top: 14px; }
.burger__line:nth-child(2) { top: 21px; }
.burger__line:nth-child(3) { top: 28px; }
.burger[aria-expanded='true'] .burger__line:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.burger[aria-expanded='true'] .burger__line:nth-child(2) {
opacity: 0;
}
.burger[aria-expanded='true'] .burger__line:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
@media (max-width: 768px) {
.burger { display: block; }
.nav {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 0;
overflow: hidden;
visibility: hidden;
background: #fff;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
transition: max-height 0.3s ease, visibility 0.3s;
}
.nav.is-open {
max-height: 80vh;
visibility: visible;
}
.nav ul {
flex-direction: column;
gap: 0;
}
.nav a {
display: block;
padding: 14px 20px;
}
}
body.no-scroll { overflow: hidden; }
Здесь иконка устроена так: три линии стоят на высоте 14, 21 и 28 пикселей, расстояние между ними 7. Поэтому для превращения в крестик крайние линии сдвигаются ровно на 7px к центру и поворачиваются на 45 градусов, а средняя гаснет. Если поменяете отступы, цифры в translateY нужно пересчитать, иначе крестик получится кривым.
Кнопку делаю 44 на 44 пикселя: на телефоне в иконку шириной 20px попасть пальцем неприятно, а размер около 44px считается нормальной областью касания.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
JavaScript: открытие и закрытие меню-гамбургера
Вся логика сводится к одной функции, которая принимает состояние и применяет его сразу в трёх местах: атрибут кнопки, класс блока и класс body. Когда состояние меняется из одного места, меню не рассинхронизируется: я видел реализации, где кнопка показывала крестик, а меню было закрыто, потому что состояние хранилось в двух переменных.
const burger = document.querySelector('.burger');
const nav = document.getElementById('nav');
function setMenu(open) {
burger.setAttribute('aria-expanded', String(open));
burger.setAttribute('aria-label', open ? 'Закрыть меню' : 'Открыть меню');
nav.classList.toggle('is-open', open);
document.body.classList.toggle('no-scroll', open);
}
burger.addEventListener('click', () => {
const isOpen = burger.getAttribute('aria-expanded') === 'true';
setMenu(!isOpen);
});
// клик по ссылке закрывает меню
nav.addEventListener('click', (e) => {
if (e.target.closest('a')) setMenu(false);
});
// клик мимо шапки закрывает меню
document.addEventListener('click', (e) => {
if (!e.target.closest('.header')) setMenu(false);
});
// Escape закрывает и возвращает фокус на кнопку
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && burger.getAttribute('aria-expanded') === 'true') {
setMenu(false);
burger.focus();
}
});
// при расширении окна сбрасываем состояние
window.matchMedia('(min-width: 769px)').addEventListener('change', (e) => {
if (e.matches) setMenu(false);
});
Скрипт подключаю в конце body или с атрибутом defer, чтобы кнопка к моменту запуска уже была в DOM. Состояние читаю из aria-expanded, а не из отдельной переменной: атрибут и есть источник правды, и его же видит скринридер.
Последний обработчик с matchMedia закрывает частый баг. Пользователь открыл меню на планшете, повернул экран в альбомную ориентацию, меню стало горизонтальным, а на body осталась блокировка прокрутки, и страница не скроллится. Сброс состояния при смене ширины это лечит.
Доступность и управление с клавиатуры
В минимальный набор я включаю четыре вещи, и все они уже есть в коде выше:
- настоящий
buttonсaria-expandedиaria-controls; - меняющаяся подпись
aria-label: «Открыть меню» и «Закрыть меню»; - закрытие по Escape с возвратом фокуса на кнопку;
- скрытые ссылки не попадают в порядок табуляции благодаря
visibility: hidden.
Полноценную ловушку фокуса (focus trap) для выпадающей навигации я обычно не делаю: это нужно модальным окнам, а меню под шапкой остаётся частью страницы, и пользователь вправе уйти из него табуляцией дальше. Если же меню разворачивается на весь экран поверх контента, тогда ловушка уместна, и без неё лучше не обходиться.
Ещё один пункт, который часто забывают: видимый фокус. Не убирайте outline у кнопки и ссылок. Если стандартная рамка не вписывается в дизайн, замените её своей через :focus-visible, но не удаляйте совсем.
Мобильное меню без JS и другие способы
Скрипт для бургера не единственный путь. Я сравнивал три подхода на реальных вёрстках, и вот как они выглядят по существу:
| Способ | Нужен JS | Доступность | Когда беру |
|---|---|---|---|
| Кнопка и переключение класса | Да | Полный контроль над aria и фокусом | Основной вариант для сайтов |
| Скрытый checkbox и label (CSS-хак) | Нет | Хуже: состояние в скрытом поле, нет aria-expanded и связи с навигацией | Статичные страницы, где скрипты запрещены |
| Элементы details и summary | Нет | Встроенная семантика раскрытия | Простые списки, когда анимация не нужна |
CSS-хак с чекбоксом выглядит привлекательно, потому что работает без скриптов, но платит за это семантикой: состояние живёт в скрытом поле, а не в атрибуте кнопки. Для коммерческого сайта я выбираю первый вариант. Он же проще всего расширяется: добавить подменю, закрытие по клику снаружи или аналитику события можно в той же функции.
Меню-гамбургер на Tilda и WordPress
На Тильде у меню в блоках уже есть мобильная версия со своим бургером, и в большинстве случаев переделывать её не нужно. Свой код пригождается, когда нужна нестандартная шапка в Zero Block, особое поведение подпунктов или привязка к вашей вёрстке. Тогда разметку и стили кладу в HTML-вставку, а скрипт подключаю отдельным блоком, при этом селекторы делаю с собственным префиксом классов, чтобы не пересечься со стилями самой платформы. Если такой доработки в проекте набирается много, я оформляю её как кастомный скрипт для Tilda, простая доработка стоит от 3 000 ₽.
В WordPress схема другая. Вёрстку выводит шаблон темы, а скрипт нужно подключать через wp_enqueue_script в functions.php дочерней темы. Не вставляйте код прямо в редактор записи: блочный редактор может почистить разметку при конвертации блока Custom HTML, к тому же код останется только на одной странице и не будет кэшироваться как отдельный ассет. Классы лучше сопоставить с теми, что выводит wp_nav_menu, либо передать свои через параметры menu_class и container_class.
Ошибки адаптивной навигации, которые я чаще всего встречаю
- Меню закрыто, но ссылки доступны по Tab. Причина в том, что скрытие сделано только через
max-heightилиopacity. Добавляйтеvisibility: hidden. - Страница прокручивается под открытым меню. Решается классом на body с
overflow: hidden, как в примере. На iOS при очень длинном меню этого иногда мало, тогда я ограничиваю высоту панели и даю ей собственный скролл. - Меню открывается, но не закрывается после перехода по якорю. На одностраничниках клик по ссылке не перезагружает страницу, поэтому закрытие нужно вешать на сам клик, как в обработчике на
nav. - Фиксированная высота панели. Если задать, например,
max-height: 300px, добавленный пункт обрежется. Я оставляю потолок в долях высоты окна (80vh), а не в пикселях. - Слишком маленькая кнопка. Иконка 20 на 20 пикселей на телефоне вызывает промахи. Область касания лучше держать около 44px, как в стилях выше.
- Обработчик навешан до появления кнопки. Скрипт в head без
deferпадает с ошибкой:querySelectorвозвращает null, а следующая строка вызывает у nulladdEventListener.
Перед релизом проверяю меню в трёх сценариях: пальцем на реальном телефоне, только клавиатурой (Tab, Enter, Escape) и в режиме эмуляции устройств в DevTools с поворотом экрана. Первых двух хватает, чтобы найти большую часть проблем, а поворот ловит именно историю с зависшей блокировкой прокрутки.
Поддержка браузерами CSS и JS-интерфейсов постепенно меняется, поэтому перед внедрением я сверяюсь с актуальной документацией MDN.
Частые вопросы
Можно ли сделать бургер меню без JavaScript?
Можно, через скрытый checkbox и label либо через элементы details и summary. Но у checkbox-варианта состояние живёт в скрытом поле, без aria-expanded и связи кнопки с блоком навигации, а details не даёт плавной анимации и нормального закрытия по клику вне меню. Для рабочего сайта я всё же беру кнопку и десяток строк скрипта.
Какой брейкпоинт выбрать для мобильного меню?
Удобная стартовая точка это 768px, как в примере выше. Но ориентироваться лучше на контент: сузьте окно и посмотрите, когда пункты перестают помещаться в одну строку. Именно на этой ширине и нужно переключать меню.
Почему меню не закрывается при клике на пункт?
Скорее всего, обработчик клика по ссылкам не навешан, а страница не перезагружается (якоря, SPA). Добавьте обработчик на контейнер навигации, который вызывает закрытие, если клик пришёлся на элемент a, как в скрипте выше.
Нужен ли aria-expanded, если меню и так видно?
Нужен. Видимое состояние доступно только зрячим пользователям, а атрибут сообщает скринридеру, раскрыто меню или свёрнуто. Он же удобен как селектор в CSS: иконка превращается в крестик по этому же атрибуту, и дополнительный класс не нужен.