Разработка · 9 мин чтения

Бургер меню HTML CSS JS: адаптивная навигация без библиотек

Бургер меню 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, а следующая строка вызывает у null addEventListener.

Перед релизом проверяю меню в трёх сценариях: пальцем на реальном телефоне, только клавиатурой (Tab, Enter, Escape) и в режиме эмуляции устройств в DevTools с поворотом экрана. Первых двух хватает, чтобы найти большую часть проблем, а поворот ловит именно историю с зависшей блокировкой прокрутки.

Поддержка браузерами CSS и JS-интерфейсов постепенно меняется, поэтому перед внедрением я сверяюсь с актуальной документацией MDN.

Частые вопросы

Можно ли сделать бургер меню без JavaScript?

Можно, через скрытый checkbox и label либо через элементы details и summary. Но у checkbox-варианта состояние живёт в скрытом поле, без aria-expanded и связи кнопки с блоком навигации, а details не даёт плавной анимации и нормального закрытия по клику вне меню. Для рабочего сайта я всё же беру кнопку и десяток строк скрипта.

Какой брейкпоинт выбрать для мобильного меню?

Удобная стартовая точка это 768px, как в примере выше. Но ориентироваться лучше на контент: сузьте окно и посмотрите, когда пункты перестают помещаться в одну строку. Именно на этой ширине и нужно переключать меню.

Почему меню не закрывается при клике на пункт?

Скорее всего, обработчик клика по ссылкам не навешан, а страница не перезагружается (якоря, SPA). Добавьте обработчик на контейнер навигации, который вызывает закрытие, если клик пришёлся на элемент a, как в скрипте выше.

Нужен ли aria-expanded, если меню и так видно?

Нужен. Видимое состояние доступно только зрячим пользователям, а атрибут сообщает скринридеру, раскрыто меню или свёрнуто. Он же удобен как селектор в CSS: иконка превращается в крестик по этому же атрибуту, и дополнительный класс не нужен.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно