Dropdown меню

Зачем нужно

Dropdown (выпадающее меню) -- базовый компонент навигации. Используется в хедерах сайтов, контекстных меню, меню действий. Правильная реализация включает CSS-only вариант для простых случаев, JS-вариант с полной клавиатурной навигацией и поддержку доступности.

Где используется

  • Навигационное меню сайта с подменю
  • Меню действий (кнопка с выпадающим списком)
  • Контекстное меню (правая кнопка мыши)
  • Select-подобные кастомные компоненты
  • Меню профиля пользователя

Подход 1: CSS-only Dropdown (hover)

Простейший вариант -- меню показывается при наведении. Подходит для навигации на desktop.

HTML

<nav class="navbar">
  <ul class="nav-list">
    <li class="nav-item">
      <a href="/" class="nav-link">Главная</a>
    </li>
    <li class="nav-item has-dropdown">
      <a href="#" class="nav-link">Каталог</a>
      <ul class="dropdown">
        <li><a href="/phones" class="dropdown__link">Телефоны</a></li>
        <li><a href="/laptops" class="dropdown__link">Ноутбуки</a></li>
        <li><a href="/tablets" class="dropdown__link">Планшеты</a></li>
      </ul>
    </li>
    <li class="nav-item">
      <a href="/about" class="nav-link">О нас</a>
    </li>
  </ul>
</nav>

CSS

.nav-list {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
  gap: 4px;
}

.nav-item {
  position: relative;
}

.nav-link {
  display: block;
  padding: 12px 16px;
  text-decoration: none;
  color: #333;
  border-radius: 8px;
  transition: background 0.2s;
}

.nav-link:hover {
  background: #f0f0f0;
}

/* Dropdown по умолчанию скрыт */
.dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 200px;
  list-style: none;
  padding: 8px 0;
  margin: 0;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px);
  transition: opacity 0.2s, transform 0.2s, visibility 0.2s;
}

/* Показать при hover на родителя */
.has-dropdown:hover .dropdown,
.has-dropdown:focus-within .dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.dropdown__link {
  display: block;
  padding: 8px 16px;
  text-decoration: none;
  color: #333;
  transition: background 0.15s;
}

.dropdown__link:hover {
  background: #f5f5f5;
}

:focus-within позволяет показывать dropdown при Tab-навигации -- важно для доступности.


Подход 2: JS Toggle Dropdown (клик)

Более надёжный вариант -- меню открывается/закрывается по клику. Работает на мобильных устройствах.

HTML

<div class="dropdown-wrapper">
  <button class="dropdown-trigger" aria-expanded="false" aria-haspopup="true"
          aria-controls="actionsMenu" id="menuBtn">
    Действия
    <svg class="dropdown-trigger__icon" width="16" height="16" viewBox="0 0 16 16">
      <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="2"/>
    </svg>
  </button>

  <ul class="dropdown-menu" id="actionsMenu" role="menu" aria-labelledby="menuBtn">
    <li role="none">
      <a href="#" role="menuitem" class="dropdown-menu__item">Редактировать</a>
    </li>
    <li role="none">
      <a href="#" role="menuitem" class="dropdown-menu__item">Дублировать</a>
    </li>
    <li role="separator" class="dropdown-menu__separator"></li>
    <li role="none">
      <a href="#" role="menuitem" class="dropdown-menu__item dropdown-menu__item--danger">
        Удалить
      </a>
    </li>
  </ul>
</div>

CSS

.dropdown-wrapper {
  position: relative;
  display: inline-block;
}

.dropdown-trigger {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #fff;
  cursor: pointer;
  font-size: 14px;
}

.dropdown-trigger__icon {
  transition: transform 0.2s;
}

.dropdown-trigger[aria-expanded="true"] .dropdown-trigger__icon {
  transform: rotate(180deg);
}

.dropdown-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 180px;
  list-style: none;
  margin: 0;
  padding: 4px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  opacity: 0;
  visibility: hidden;
  transform: scale(0.95);
  transform-origin: top left;
  transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
}

.dropdown-menu.open() {
  opacity: 1;
  visibility: visible;
  transform: scale(1);
}

.dropdown-menu__item {
  display: block;
  padding: 8px 12px;
  text-decoration: none;
  color: #333;
  border-radius: 6px;
  font-size: 14px;
  transition: background 0.1s;
}

.dropdown-menu__item:hover,
.dropdown-menu__item:focus {
  background: #f5f5f5;
  outline: none;
}

.dropdown-menu__item--danger {
  color: #e53e3e;
}

.dropdown-menu__separator {
  height: 1px;
  background: #eee;
  margin: 4px 0;
}

JavaScript с клавиатурной навигацией

class Dropdown {
  constructor(wrapper) {
    this.wrapper = wrapper;
    this.trigger = wrapper.querySelector('.dropdown-trigger');
    this.menu = wrapper.querySelector('.dropdown-menu');
    this.items = [...this.menu.querySelectorAll('[role="menuitem"]')];
    this.isOpen = false;
    this.focusedIndex = -1;

    this.trigger.addEventListener('click', () => this.toggle);
    this.trigger.addEventListener('keydown', (e) => this.handleTriggerKey(e));
    this.menu.addEventListener('keydown', (e) => this.handleMenuKey(e));

    // Закрытие при клике вне dropdown
    document.addEventListener('click', (e) => {
      if (!this.wrapper.contains(e.target)) this.close();
    });
  }

  toggle {
    this.isOpen ? this.close() : this.open();
  }

  open {
    this.isOpen = true;
    this.menu.classList.add('open');
    this.trigger.setAttribute('aria-expanded', 'true');
    // Фокус на первый элемент
    this.focusItem(0);
  }

  close {
    this.isOpen = false;
    this.menu.classList.remove('open');
    this.trigger.setAttribute('aria-expanded', 'false');
    this.focusedIndex = -1;
    this.trigger.focus();
  }

  focusItem(index) {
    if (index < 0) index = this.items.length - 1;
    if (index >= this.items.length) index = 0;
    this.focusedIndex = index;
    this.items[index].focus();
  }

  handleTriggerKey(e) {
    switch (e.key) {
      case 'ArrowDown':
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.open();
        break;
      case 'ArrowUp':
        e.preventDefault();
        this.open();
        this.focusItem(this.items.length - 1);
        break;
    }
  }

  handleMenuKey(e) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        this.focusItem(this.focusedIndex + 1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        this.focusItem(this.focusedIndex - 1);
        break;
      case 'Home':
        e.preventDefault();
        this.focusItem(0);
        break;
      case 'End':
        e.preventDefault();
        this.focusItem(this.items.length - 1);
        break;
      case 'Escape':
        this.close();
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.items[this.focusedIndex]?.click();
        this.close();
        break;
    }
  }
}

// Инициализация
document.querySelectorAll('.dropdown-wrapper').forEach((el) => new Dropdown(el));

Вложенные подменю (submenu)

<li class="has-submenu" role="none">
  <a href="#" role="menuitem" aria-haspopup="true" aria-expanded="false">
    Экспорт
    <span class="submenu-arrow">&rsaquo;</span>
  </a>
  <ul class="submenu" role="menu">
    <li role="none"><a href="#" role="menuitem">PDF</a></li>
    <li role="none"><a href="#" role="menuitem">CSV</a></li>
    <li role="none"><a href="#" role="menuitem">JSON</a></li>
  </ul>
</li>
.has-submenu {
  position: relative;
}

.submenu {
  position: absolute;
  left: 100%;
  top: 0;
  min-width: 150px;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s, visibility 0.15s;
}

.has-submenu:hover > .submenu,
.has-submenu:focus-within > .submenu {
  opacity: 1;
  visibility: visible;
}

Accessibility-чеклист

Атрибут Назначение
aria-expanded Состояние открыт/закрыт
aria-haspopup="true" Кнопка открывает popup
aria-controls Связывает кнопку с меню
role="menu" Контейнер -- меню
role="menuitem" Пункт меню
Arrow Up/Down Навигация между пунктами
Enter/Space Активация пункта
Escape Закрытие меню
Home/End Первый/последний пункт

Частые ошибки

Ошибка Проблема Решение
Только hover без клика Не работает на мобильных Добавь JS toggle по клику
Нет клавиатурной навигации Недоступно без мыши Arrow keys + Enter + Escape
Меню не закрывается при клике вне Висит поверх контента document.addEventListener('click', ...)
Нет aria-expanded Скринридер не знает состояние Переключай атрибут при открытии/закрытии
Submenu вылезает за viewport Не помещается справа Проверяй getBoundingClientRect

Связанные темы

Ресурсы