Как сделать адаптивное меню на JavaScript

Адаптивное меню на JavaScript превращается на мобильном экране в кнопку-гамбургер. На широком экране оно становится строкой ссылок с подменю. Вы соберете пример без библиотек: с клавиатурой, фокусом, Esc, поворотом экрана и режимом без JavaScript.

Каким должно быть адаптивное меню

Начните с обычной навигации: ссылки остаются в HTML, а JavaScript добавляет состояния. Такой подход называют прогрессивным улучшением.

  • На мобильном экране кнопка открывает боковую панель.
  • На широком экране пункты выстраиваются в строку.
  • Клавиатура открывает подменю и закрывает панель.
  • Скринридер получает название навигации и состояния кнопок.
  • Смена ширины закрывает панель и сбрасывает подменю.

Для подменю используйте паттерн Disclosure из WAI-ARIA APG. Роли menu и menuitem здесь не нужны: обычные ссылки сохраняют привычное поведение.

Чистого CSS хватает для постоянного списка и простого раскрытия через <details>. JavaScript нужен для оверлея, Esc, блокировки прокрутки и возврата фокуса.

<details>
  <summary>Услуги</summary>
  <ul><li><a href="#sites">Сайты</a></li></ul>
</details>

HTML-разметка меню

Вынесите основной контент в #page, а панель оставьте рядом с ним. Тогда атрибут inert заблокирует содержимое страницы, но не саму навигацию.

Кнопка меняет состояние интерфейса, ссылка переводит к содержимому. Поэтому триггером подменю служит <button type="button">, а не ссылка с href="#".

<header>
  <span>Студия</span>
  <button id="menu-toggle" class="menu-toggle" type="button"
    aria-expanded="false" aria-controls="menu-panel">
    <span aria-hidden="true">☰</span> Меню
  </button>
  <div id="menu-panel" class="menu-panel" aria-label="Меню сайта">
    <button id="menu-close" class="menu-close" type="button">Закрыть меню</button>
    <nav aria-label="Основная навигация">
      <ul class="nav-list">
        <li><a href="#home">Главная</a></li>
        <li class="has-submenu">
          <button class="submenu-toggle" type="button"
            aria-expanded="false" aria-controls="services">Услуги</button>
          <ul id="services" class="submenu">
            <li><a href="#sites">Сайты</a></li>
            <li><a href="#support">Поддержка</a></li>
          </ul>
        </li>
        <li><a href="#contacts">Контакты</a></li>
      </ul>
    </nav>
  </div>
</header>
<div id="page">
  <main id="home" tabindex="-1">
    <h1>Студия разработки</h1>
    <section id="sites" tabindex="-1"><h2>Сайты</h2><p>Разработка сайтов.</p></section>
    <section id="support" tabindex="-1"><h2>Поддержка</h2><p>Поддержка проектов.</p></section>
    <section id="contacts" tabindex="-1"><h2>Контакты</h2><p>Напишите нашей команде.</p></section>
  </main>
</div>
<div id="overlay" class="overlay" hidden aria-hidden="true"></div>

Атрибут aria-expanded сообщает состояние раскрытия. aria-controls связывает кнопку с идентификатором списка.

Без скрипта навигация остается в документе. Ссылки работают в обычном потоке страницы, поэтому пользователь сохраняет доступ к разделам.

CSS: мобильная панель и десктопная строка

Задайте мобильные правила первыми. Медиа-запрос переключит панель в строку на ширине от 60rem. Это значение дублируется в JavaScript, поэтому при изменении порога исправьте оба вхождения.

* { box-sizing: border-box; }
body { margin: 0; font: 1rem/1.5 system-ui, sans-serif; }
header, main { padding: 1rem; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
a { color: #124e91; }
button { font: inherit; cursor: pointer; }
a, button { padding: .65rem; }
:focus-visible { outline: 3px solid #9c3400; outline-offset: 3px; }
ul { list-style: none; margin: 0; padding: 0; }
.menu-panel { padding: 1rem; background: #fff; }
.nav-list a { display: block; }
.submenu { padding-left: 1rem; }
.menu-toggle, .menu-close, .submenu-toggle { display: none; }
.has-submenu::before { content: 'Услуги'; display: block; padding: .65rem; }
[hidden] { display: none !important; }
.enhanced .has-submenu::before { display: none; }
.enhanced .submenu-toggle { display: inline-block; }
.enhanced .menu-toggle, .enhanced .menu-close { display: inline-block; }
.enhanced .menu-panel {
  position: fixed; inset: 0 auto 0 0; z-index: 20;
  width: min(85vw, 22rem); height: 100dvh; overflow-y: auto;
  overscroll-behavior: contain; transform: translateX(-100%);
  visibility: hidden; transition: transform .2s ease, visibility 0s linear .2s;
}
.enhanced .menu-panel.is-open {
  transform: translateX(0); visibility: visible; transition-delay: 0s;
}
.overlay { position: fixed; inset: 0; z-index: 10; background: rgb(0 0 0 / .45); }
body.menu-open { overflow: hidden; }
@media (min-width: 60rem) {
  header { align-items: center; }
  .nav-list { display: flex; gap: 1rem; align-items: flex-start; }
  .has-submenu { position: relative; }
  .enhanced .menu-toggle, .enhanced .menu-close { display: none; }
  .enhanced .menu-panel {
    position: relative; inset: auto; width: auto; height: auto;
    overflow: visible; transform: none; visibility: visible; transition: none;
  }
  .enhanced .submenu {
    position: absolute; top: 100%; left: 0; z-index: 30;
    min-width: 12rem; padding: .5rem; background: #fff; border: 1px solid #777;
  }
}
@media (prefers-reduced-motion: reduce) {
  .enhanced .menu-panel { transition: none; }
}

Закрытая панель скрывается через visibility и уезжает с помощью transform. При инициализации скрипт добавляет класс enhanced элементу html.

JavaScript: открытие и закрытие панели

Соберите изменения состояния в функции setPanel(). Она синхронизирует классы, ARIA, прокрутку, фокус и доступность фона.

При открытии фокус переходит на кнопку закрытия. Оверлей и Esc закрывают панель, а фокус возвращается на гамбургер.

Свойство inert исключает #page из взаимодействия. Обработчик Tab удерживает фокус внутри открытой панели.

Выпадающие подменю

На мобильном экране кнопка раскрывает список по клику. На десктопе список открывается наведением или фокусом.

После наведения клик по той же кнопке оставляет подменю открытым. Флаг openedAutomatically отличает автоматическое раскрытие от обычного клика: обработчик не закрывает список сразу после показа.

Смена брейкпоинта и поворот экрана

Отслеживайте медиазапрос через matchMedia(). Событие change возникает при переходе между режимами.

const wide = matchMedia('(min-width: 60rem)');
wide.addEventListener('change', resetLayout);

Функция resetLayout() закрывает панель, сбрасывает подменю и снимает блокировку страницы. В этом автономном примере значение 60rem указано отдельно в CSS и JavaScript, поэтому его нужно менять в обоих местах.

Анимации и prefers-reduced-motion

Панель выезжает за 0,2 секунды через transform. При закрытии скрипт сразу включает inert, поэтому ссылки уезжающей панели недоступны для Tab.

Медиа-функция prefers-reduced-motion учитывает настройку пользователя и отключает анимацию панели.

Проверка меню

Сохраните итоговый пример как index.html и откройте его браузером. В узком окне появится кнопка «Меню», а от 60rem навигация станет строкой.

  1. Включите эмуляцию мобильного экрана в инструментах разработчика.
  2. Откройте панель и пройдите ее клавишами Tab и Shift+Tab.
  3. Раскройте «Услуги» клавишами Enter и Space.
  4. Нажмите Esc внутри подменю, затем еще раз внутри панели.
  5. Наведите указатель на «Услуги» и нажмите ту же кнопку. Список останется открытым.
  6. Нажмите оверлей. Панель закроется без перехода к содержимому под ним.
  7. Выберите ссылку «Сайты». Панель исчезнет, а фокус перейдет к разделу.
  8. Измените ширину через 60rem. Панель и подменю закроются.
  9. Отключите JavaScript. Ссылки останутся видимыми в шапке.
  10. Включите уменьшение движения. Панель переключится без анимации.

Пройдите сценарий скринридером. Найдите область «Основная навигация» и прослушайте состояние кнопки «Услуги» до и после раскрытия.

Запустите аудит доступности Lighthouse в Chrome DevTools. Порядок фокуса, переходы по ссылкам и закрытие панели проверяйте вручную.

Ошибки в мобильном меню

  • Ссылка вместо кнопки. Замените <a href="#"> на <button>, если элемент меняет состояние.
  • Нет aria-expanded. Обновляйте атрибут вместе со скрытием списка.
  • Фокус уходит за панель. Используйте inert для фона и возвращайте фокус на «Меню».
  • Панель остается после перехода. Закрывайте ее при выборе обычной ссылки.
  • Подменю работает только через :hover. Добавьте кнопку и обработчик клика.
  • Состояние сохраняется после поворота. Сбрасывайте его в обработчике change.
  • Пороги расходятся. Меняйте одинаковое значение в CSS и JavaScript.

Итоговый код целиком

Скопируйте блок в файл index.html. Он содержит разметку, стили и скрипт без внешних зависимостей.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Адаптивное меню</title>
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; font: 1rem/1.5 system-ui, sans-serif; }
    header, main { padding: 1rem; }
    header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
    a { color: #124e91; }
    button { font: inherit; cursor: pointer; }
    a, button { padding: .65rem; }
    :focus-visible { outline: 3px solid #9c3400; outline-offset: 3px; }
    ul { list-style: none; margin: 0; padding: 0; }
    .menu-panel { padding: 1rem; background: #fff; }
    .nav-list a { display: block; }
    .submenu { padding-left: 1rem; }
    .menu-toggle, .menu-close, .submenu-toggle { display: none; }
    .has-submenu::before { content: 'Услуги'; display: block; padding: .65rem; }
    [hidden] { display: none !important; }
    .enhanced .has-submenu::before { display: none; }
    .enhanced .submenu-toggle { display: inline-block; }
    .enhanced .menu-toggle, .enhanced .menu-close { display: inline-block; }
    .enhanced .menu-panel { position: fixed; inset: 0 auto 0 0; z-index: 20; width: min(85vw, 22rem); height: 100dvh; overflow-y: auto; overscroll-behavior: contain; transform: translateX(-100%); visibility: hidden; transition: transform .2s ease, visibility 0s linear .2s; }
    .enhanced .menu-panel.is-open { transform: translateX(0); visibility: visible; transition-delay: 0s; }
    .overlay { position: fixed; inset: 0; z-index: 10; background: rgb(0 0 0 / .45); }
    body.menu-open { overflow: hidden; }
    @media (min-width: 60rem) {
      header { align-items: center; }
      .nav-list { display: flex; gap: 1rem; align-items: flex-start; }
      .has-submenu { position: relative; }
      .enhanced .menu-toggle, .enhanced .menu-close { display: none; }
      .enhanced .menu-panel { position: relative; inset: auto; width: auto; height: auto; overflow: visible; transform: none; visibility: visible; transition: none; }
      .enhanced .submenu { position: absolute; top: 100%; left: 0; z-index: 30; min-width: 12rem; padding: .5rem; background: #fff; border: 1px solid #777; }
    }
    @media (prefers-reduced-motion: reduce) { .enhanced .menu-panel { transition: none; } }
  </style>
</head>
<body>
  <header>
    <span>Студия</span>
    <button id="menu-toggle" class="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-panel"><span aria-hidden="true">☰</span> Меню</button>
    <div id="menu-panel" class="menu-panel" aria-label="Меню сайта">
      <button id="menu-close" class="menu-close" type="button">Закрыть меню</button>
      <nav aria-label="Основная навигация">
        <ul class="nav-list">
          <li><a href="#home">Главная</a></li>
          <li class="has-submenu"><button class="submenu-toggle" type="button" aria-expanded="false" aria-controls="services">Услуги</button>
            <ul id="services" class="submenu"><li><a href="#sites">Сайты</a></li><li><a href="#support">Поддержка</a></li></ul>
          </li>
          <li><a href="#contacts">Контакты</a></li>
        </ul>
      </nav>
    </div>
  </header>
  <div id="page">
    <main id="home" tabindex="-1">
      <h1>Студия разработки</h1>
      <section id="sites" tabindex="-1"><h2>Сайты</h2><p>Разработка сайтов.</p></section>
      <section id="support" tabindex="-1"><h2>Поддержка</h2><p>Поддержка проектов.</p></section>
      <section id="contacts" tabindex="-1"><h2>Контакты</h2><p>Напишите нашей команде.</p></section>
    </main>
  </div>
  <div id="overlay" class="overlay" hidden aria-hidden="true"></div>
  <script>
  (() => {
    if (!('inert' in HTMLElement.prototype)) return;
    const wide = matchMedia('(min-width: 60rem)');
    if (!wide.addEventListener) return;
    const page = document.getElementById('page');
    const panel = document.getElementById('menu-panel');
    const toggle = document.getElementById('menu-toggle');
    const closeButton = document.getElementById('menu-close');
    const overlay = document.getElementById('overlay');
    const subButtons = [...panel.querySelectorAll('.submenu-toggle')];
    let panelOpen = false;
    function setSubmenu(button, open) {
      const submenu = document.getElementById(button.getAttribute('aria-controls'));
      button.setAttribute('aria-expanded', String(open));
      submenu.hidden = !open;
      if (!open) delete button.dataset.openedAutomatically;
    }
    function closeSubmenus(except = null) {
      subButtons.forEach(button => { if (button !== except) setSubmenu(button, false); });
    }
    function setPanel(open, restoreFocus = true) {
      open = open && !wide.matches;
      panelOpen = open;
      toggle.setAttribute('aria-expanded', String(open));
      panel.classList.toggle('is-open', open);
      overlay.hidden = !open;
      document.body.classList.toggle('menu-open', open);
      page.inert = open;
      if (open) {
        panel.inert = false;
        panel.setAttribute('role', 'dialog');
        panel.setAttribute('aria-modal', 'true');
        closeButton.focus();
      } else {
        panel.removeAttribute('role');
        panel.removeAttribute('aria-modal');
        if (restoreFocus && !wide.matches) toggle.focus();
        panel.inert = !wide.matches;
        closeSubmenus();
      }
    }
    function resetLayout() {
      const active = document.activeElement;
      const moveFocus = panel.contains(active) || active === toggle;
      setPanel(false, false);
      if (moveFocus) (wide.matches ? panel.querySelector('a') : toggle).focus();
    }
    toggle.addEventListener('click', () => setPanel(!panelOpen));
    closeButton.addEventListener('click', () => setPanel(false));
    overlay.addEventListener('click', () => setPanel(false));
    panel.addEventListener('click', event => {
      const link = event.target.closest('a[href]');
      if (!link || !panel.contains(link) || !panelOpen) return;
      if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
      if (link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
      const href = link.getAttribute('href');
      const target = href.startsWith('#') ? document.getElementById(decodeURIComponent(href.slice(1))) : null;
      setPanel(false, false);
      if (target) target.focus({ preventScroll: true });
    });
    subButtons.forEach(button => {
      const item = button.closest('.has-submenu');
      function openAutomatically() {
        closeSubmenus(button);
        if (button.getAttribute('aria-expanded') !== 'true') {
          button.dataset.openedAutomatically = 'true';
          setSubmenu(button, true);
        }
      }
      button.addEventListener('click', () => {
        const automatic = button.dataset.openedAutomatically === 'true';
        delete button.dataset.openedAutomatically;
        closeSubmenus(button);
        setSubmenu(button, automatic || button.getAttribute('aria-expanded') !== 'true');
      });
      item.addEventListener('pointerenter', event => { if (wide.matches && event.pointerType === 'mouse') openAutomatically(); });
      item.addEventListener('focusin', event => { if (wide.matches && event.target === button && !item.contains(event.relatedTarget)) openAutomatically(); });
      item.addEventListener('pointerleave', () => { if (wide.matches && !item.contains(document.activeElement)) setSubmenu(button, false); });
      item.addEventListener('focusout', event => { if (wide.matches && !item.contains(event.relatedTarget)) setSubmenu(button, false); });
    });
    document.addEventListener('click', event => { if (!panel.contains(event.target)) closeSubmenus(); });
    document.addEventListener('keydown', event => {
      if (event.key === 'Escape') {
        const expanded = subButtons.find(button => button.getAttribute('aria-expanded') === 'true' && button.parentElement.contains(document.activeElement));
        if (expanded) { event.preventDefault(); setSubmenu(expanded, false); expanded.focus(); return; }
        closeSubmenus();
        if (panelOpen) { event.preventDefault(); setPanel(false); }
      }
      if (event.key !== 'Tab' || !panelOpen) return;
      const focusables = [...panel.querySelectorAll('a[href], button:not([disabled])')].filter(element => element.getClientRects().length);
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
      else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
    });
    wide.addEventListener('change', resetLayout);
    document.documentElement.classList.add('enhanced');
    panel.inert = !wide.matches;
    resetLayout();
  })();
  </script>
</body>
</html>

Оставьте список ссылок в документе, чтобы базовая навигация работала без JavaScript. Панель, оверлей и управление фокусом добавляйте поверх этого сценария.

Совет от нашей редакции: сначала проверьте клавиатурный сценарий и переходы между режимами, затем подключайте анимацию.

Комментарии

Оставьте свой комментарий
Пока никто не оставил комментариев