Задача
СкопированоСоздать навигационное меню с несколькими уровнями и вложенными внутрь элементами.
Готовое решение
СкопированоПри создании меню на сайте важно использовать семантические теги, чтобы обеспечить базовую доступность и избежать дополнительных действий с JavaScript.
Готовая разметка многоуровневого меню выглядит следующим образом:
<body> <header class="header"> <nav class="site-nav" aria-label="Сайт" > <ul class="menu"> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="doka-submenu" > Дока </button> <!-- Первый уровень вложенности --> <ul class="menu menu-submenu" id="doka-submenu"> <li class="menu__item"> <a href="#" class="menu__link" aria-current="page" > Рецепты </a> <li> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="html-submenu" > HTML </button> <!-- Второй уровень вложенности --> <ul class="menu menu-submenu" id="html-submenu"> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Форматирование </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Семантика </a> </li> </ul> </li> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="css-submenu" > CSS </button> <!-- Второй уровень вложенности --> <ul class="menu menu-submenu" id="css-submenu"> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Селекторы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Псевдоклассы </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> JavaScript </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Доступность </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> Новости </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Архив </a> </li> </ul> </nav> </header></body>
<body>
<header class="header">
<nav
class="site-nav"
aria-label="Сайт"
>
<ul class="menu">
<li class="menu__item" data-has-children>
<button
class="menu__btn"
aria-expanded="false"
aria-controls="doka-submenu"
>
Дока
</button>
<!-- Первый уровень вложенности -->
<ul class="menu menu-submenu" id="doka-submenu">
<li class="menu__item">
<a
href="#"
class="menu__link"
aria-current="page"
>
Рецепты
</a>
<li>
<li class="menu__item" data-has-children>
<button
class="menu__btn"
aria-expanded="false"
aria-controls="html-submenu"
>
HTML
</button>
<!-- Второй уровень вложенности -->
<ul class="menu menu-submenu" id="html-submenu">
<li class="menu__item">
<a href="#" class="menu__link">
Основы
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Форматирование
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Семантика
</a>
</li>
</ul>
</li>
<li class="menu__item" data-has-children>
<button
class="menu__btn"
aria-expanded="false"
aria-controls="css-submenu"
>
CSS
</button>
<!-- Второй уровень вложенности -->
<ul class="menu menu-submenu" id="css-submenu">
<li class="menu__item">
<a href="#" class="menu__link">
Основы
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Селекторы
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Псевдоклассы
</a>
</li>
</ul>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
JavaScript
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Доступность
</a>
</li>
</ul>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Новости
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Блог
</a>
</li>
<li class="menu__item">
<a href="#" class="menu__link">
Архив
</a>
</li>
</ul>
</nav>
</header>
</body>
ul, li { list-style: none; padding: 0; margin: 0; text-align: start;}button:focus-visible,a:focus-visible { outline: 2px solid; outline-offset: -3px;}.header { display: flex; align-items: center; background-color: #C56FFF; padding: 0 50px;}.menu { display: flex; min-width: max-content; background: #C56FFF; color: #000000;}.menu-submenu { background: #FFFFFF;}.menu__btn,.menu__link { display: flex; width: 100%; gap: .5em; align-items: center; padding: .75rem 1.5rem; font-size: 1.125rem; font-weight: 300; font-family: inherit; color: #000000; cursor: pointer; border: none; background: transparent; transition: background-color 0.2s linear;}.menu__link:hover,.menu__btn:hover,.menu__btn[aria-expanded="true"] { background-color: #FFFFFF;}.menu-submenu .menu__link:hover,.menu-submenu .menu__btn:hover,.menu-submenu .menu__btn[aria-expanded="true"] { background-color: #C56FFF;}.menu-submenu .menu__link:focus-visible,.menu-submenu .menu__btn:focus-visible { outline-width: 2px; outline-offset: -3px; outline-style: solid; outline-color: #000000;}.menu__btn-icon { color: inherit; transition: transform .1s linear;}.menu-submenu .menu__btn-icon { transform: rotate(-90deg);}.menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(180deg);}.menu-submenu .menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(90deg);}.menu__item { position: relative;}.menu__link { text-decoration: none;}a[aria-current="page"] { font-weight: 500; color: #000000;}/* Вложенное меню */.menu .menu { display: flex; flex-direction: column; gap: 8px; padding-inline-start: 3rem;}/* Первый уровень вложенности */.enhanced .menu .menu { position: absolute; top: 110%; left: 0; padding-inline-start: 0;}/* Второй уровень вложенности */.enhanced .menu .menu .menu { top: 0; left: 104%;}.menu[hidden] { display: none;}
ul, li {
list-style: none;
padding: 0;
margin: 0;
text-align: start;
}
button:focus-visible,
a:focus-visible {
outline: 2px solid;
outline-offset: -3px;
}
.header {
display: flex;
align-items: center;
background-color: #C56FFF;
padding: 0 50px;
}
.menu {
display: flex;
min-width: max-content;
background: #C56FFF;
color: #000000;
}
.menu-submenu {
background: #FFFFFF;
}
.menu__btn,
.menu__link {
display: flex;
width: 100%;
gap: .5em;
align-items: center;
padding: .75rem 1.5rem;
font-size: 1.125rem;
font-weight: 300;
font-family: inherit;
color: #000000;
cursor: pointer;
border: none;
background: transparent;
transition: background-color 0.2s linear;
}
.menu__link:hover,
.menu__btn:hover,
.menu__btn[aria-expanded="true"] {
background-color: #FFFFFF;
}
.menu-submenu .menu__link:hover,
.menu-submenu .menu__btn:hover,
.menu-submenu .menu__btn[aria-expanded="true"] {
background-color: #C56FFF;
}
.menu-submenu .menu__link:focus-visible,
.menu-submenu .menu__btn:focus-visible {
outline-width: 2px;
outline-offset: -3px;
outline-style: solid;
outline-color: #000000;
}
.menu__btn-icon {
color: inherit;
transition: transform .1s linear;
}
.menu-submenu .menu__btn-icon {
transform: rotate(-90deg);
}
.menu__btn[aria-expanded="true"] .menu__btn-icon {
transform: rotate(180deg);
}
.menu-submenu .menu__btn[aria-expanded="true"] .menu__btn-icon {
transform: rotate(90deg);
}
.menu__item {
position: relative;
}
.menu__link {
text-decoration: none;
}
a[aria-current="page"] {
font-weight: 500;
color: #000000;
}
/* Вложенное меню */
.menu .menu {
display: flex;
flex-direction: column;
gap: 8px;
padding-inline-start: 3rem;
}
/* Первый уровень вложенности */
.enhanced .menu .menu {
position: absolute;
top: 110%;
left: 0;
padding-inline-start: 0;
}
/* Второй уровень вложенности */
.enhanced .menu .menu .menu {
top: 0;
left: 104%;
}
.menu[hidden] {
display: none;
}
const nav = document.querySelector('.site-nav')nav.classList.add('enhanced')const submenus = nav.querySelectorAll( '.menu__item[data-has-children]')const dropdowns = nav.querySelectorAll( '.menu__item[data-has-children] > .menu')const icon = ` <svg width="24px" height="24px" viewBox="0 0 24 24" aria-hidden="true" class="menu__btn-icon" > <path fill="currentColor" d="M5.64645 8.64645c.19526-.19527.51184-.19527.7071 0L12 14.2929l5.6464-5.64645c.1953-.19527.5119-.19527.7072 0 .1952.19526.1952.51184 0 .7071L12 15.7071 5.64645 9.35355c-.19527-.19526-.19527-.51184 0-.7071Z"></path> </svg>`// Находим подменю, заменяем в нём span на кнопкуsubmenus.forEach((item) => { const dropdown = item.querySelector(':scope > .menu') dropdown.setAttribute('hidden', '') const button = item.querySelector(':scope > .menu__btn') // Добавляем иконку к кнопке, чтобы визуально было // понятно открыто меню или нет button.innerHTML += icon button.addEventListener('click', function (e) { toggleDropdown(button, dropdown) }) // Обрабатываем нажатие на Esc dropdown.addEventListener('keydown', (e) => { e.stopImmediatePropagation() if (e.keyCode === 27 && focusIsInside(dropdown)) { toggleDropdown(button, dropdown) button.focus() } }, false)})function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') }}function focusIsInside(element) { return element.contains(document.activeElement)}function collapseDropdownsWhenTabbingOutsideNav(e) { if (e.keyCode === 9 && !focusIsInside(nav)) { dropdowns.forEach(function (dropdown) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') }) }}function collapseDropdownsWhenClickingOutsideNav(e) { const target = e.target dropdowns.forEach(function (dropdown) { if (!dropdown.parentNode.contains(target)) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') } });}// Закрываем навигацию, если протапались за её пределыdocument.addEventListener('keyup', collapseDropdownsWhenTabbingOutsideNav)// Закрываем навигацию, если кликнули вне навигацииwindow.addEventListener('click', collapseDropdownsWhenClickingOutsideNav)
const nav = document.querySelector('.site-nav')
nav.classList.add('enhanced')
const submenus = nav.querySelectorAll(
'.menu__item[data-has-children]'
)
const dropdowns = nav.querySelectorAll(
'.menu__item[data-has-children] > .menu'
)
const icon = `
<svg
width="24px"
height="24px"
viewBox="0 0 24 24"
aria-hidden="true"
class="menu__btn-icon"
>
<path fill="currentColor" d="M5.64645 8.64645c.19526-.19527.51184-.19527.7071 0L12 14.2929l5.6464-5.64645c.1953-.19527.5119-.19527.7072 0 .1952.19526.1952.51184 0 .7071L12 15.7071 5.64645 9.35355c-.19527-.19526-.19527-.51184 0-.7071Z"></path>
</svg>
`
// Находим подменю, заменяем в нём span на кнопку
submenus.forEach((item) => {
const dropdown = item.querySelector(':scope > .menu')
dropdown.setAttribute('hidden', '')
const button = item.querySelector(':scope > .menu__btn')
// Добавляем иконку к кнопке, чтобы визуально было
// понятно открыто меню или нет
button.innerHTML += icon
button.addEventListener('click', function (e) {
toggleDropdown(button, dropdown)
})
// Обрабатываем нажатие на Esc
dropdown.addEventListener('keydown', (e) => {
e.stopImmediatePropagation()
if (e.keyCode === 27 && focusIsInside(dropdown)) {
toggleDropdown(button, dropdown)
button.focus()
}
}, false)
})
function toggleDropdown(button, dropdown) {
if (button.getAttribute('aria-expanded') === 'true') {
button.setAttribute('aria-expanded', 'false')
dropdown.setAttribute('hidden', '')
} else {
button.setAttribute('aria-expanded', 'true')
dropdown.removeAttribute('hidden')
}
}
function focusIsInside(element) {
return element.contains(document.activeElement)
}
function collapseDropdownsWhenTabbingOutsideNav(e) {
if (e.keyCode === 9 && !focusIsInside(nav)) {
dropdowns.forEach(function (dropdown) {
dropdown.setAttribute('hidden', '')
const btn = dropdown.parentNode.querySelector('button')
btn.setAttribute('aria-expanded', 'false')
})
}
}
function collapseDropdownsWhenClickingOutsideNav(e) {
const target = e.target
dropdowns.forEach(function (dropdown) {
if (!dropdown.parentNode.contains(target)) {
dropdown.setAttribute('hidden', '')
const btn = dropdown.parentNode.querySelector('button')
btn.setAttribute('aria-expanded', 'false')
}
});
}
// Закрываем навигацию, если протапались за её пределы
document.addEventListener('keyup', collapseDropdownsWhenTabbingOutsideNav)
// Закрываем навигацию, если кликнули вне навигации
window.addEventListener('click', collapseDropdownsWhenClickingOutsideNav)
Итоговый результат выглядит так:
Разбор решения
СкопированоПервый уровень
СкопированоПри создании многоуровневого меню сначала создадим первый, базовый уровень. Семантическая вёрстка будет выглядеть следующим образом:
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item"> <a href="#" class="menu__link">Дока</a> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link">Блог</a> </li> </ul></nav>
<nav class="site-nav" aria-label="Сайт">
<ul class="menu">
<li class="menu__item">
<a href="#" class="menu__link">Дока</a>
</li>
<!-- Другие элементы -->
<li class="menu__item">
<a href="#" class="menu__link">Блог</a>
</li>
</ul>
</nav>
Для базовой обёртки в большинстве случаев лучше использовать тег <nav>. Он явно указывает браузеру на свою роль: говорит о том, что является ориентиром.
Встроенная роль <nav> позволяет вспомогательным технологиям понять, для чего нужен элемент и корректно рассказать о нём пользователям. Например, пользователи скринридеров смогут попасть в такое меню с помощью специальных сочетаний клавиш и, тем самым, быстрее взаимодействовать со страницей.
Хорошо, когда у навигации есть доступное имя. Оно поможет отличить одно меню от другого, когда на сайте несколько вариантов меню. Например, основная навигация по сайту и навигация с хлебными крошками по категориям товаров. В примере для задания доступного имени используем ARIA-атрибут aria.
Также важно рассказать пользователям о том, что они взаимодействуют с набором связанных элементов. Для этого будем использовать тег <ul>, который подскажет вспомогательным технологиям сколько элементов в списке. Использование данного тега и aria также помогут в определении уровня меню, на котором сейчас находится пользователь.
Вложенные уровни
СкопированоВнутрь базового уровня меню можно вложить ещё один. Для этого добавьте внутрь элемента списка другой список и заголовок нового уровня. В нашем примере в пункт меню «Дока» добавлен ещё один список с классом .menu.
В большинстве случаев для элемента заголовка используют кнопку <button>. Использование кнопки позволяет попасть на элемент меню с помощью клавиши Tab и повесить на неё событие click, которое вызывается с помощью нажатия на Enter или пробел. Это особенно важно для людей, которые не используют мышку для навигации по сайту.
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="doka-menu" > Дока </button> <ul class="menu" id="doka-menu"> <a href="#" class="menu__link">HTML</a> <a href="#" class="menu__link">CSS</a> <a href="#" class="menu__link">JavaScript</a> <a href="#" class="menu__link">Доступность</a> </ul> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> </ul></nav>
<nav class="site-nav" aria-label="Сайт">
<ul class="menu">
<li class="menu__item" data-has-children>
<button
class="menu__btn"
aria-expanded="false"
aria-controls="doka-menu"
>
Дока
</button>
<ul class="menu" id="doka-menu">
<a href="#" class="menu__link">HTML</a>
<a href="#" class="menu__link">CSS</a>
<a href="#" class="menu__link">JavaScript</a>
<a href="#" class="menu__link">Доступность</a>
</ul>
</li>
<!-- Другие элементы -->
<li class="menu__item">
<a href="#" class="menu__link">
Блог
</a>
</li>
</ul>
</nav>
В примере к кнопке добавлены ARIA-атрибуты, которые помогают вспомогательным технологиям лучше взаимодействовать с элементами на странице. Атрибут aria указывает открыт ли пункт меню или нет. aria связывает кнопку со списком, который она разворачивает или сворачивает.
В таком случае нужно будет написать небольшой скрипт на JavaScript, чтобы можно изменять значение атрибута aria при взаимодействии с кнопкой.
button.addEventListener('click', function (e) { toggleDropdown(button, dropdown)})function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') }}
button.addEventListener('click', function (e) {
toggleDropdown(button, dropdown)
})
function toggleDropdown(button, dropdown) {
if (button.getAttribute('aria-expanded') === 'true') {
button.setAttribute('aria-expanded', 'false')
dropdown.setAttribute('hidden', '')
} else {
button.setAttribute('aria-expanded', 'true')
dropdown.removeAttribute('hidden')
}
}
Если нужно, чтобы элемент навигации был одновременно и ссылкой на родительскую директорию, и содержал вложенную информацию, можно обернуть текст в <a>, а рядом с ней расположить <button> со стрелкой, которая будет открывать и закрывать список. В рецепте не рассматриваем этот паттерн, но его не так сложно реализовать самостоятельно.

Процесс вложения одного списка в другой может повторяться столько раз, сколько нужно. На практике довольно редко встречается больше трёх уровней.
Стили
СкопированоСтили для меню могут быть абсолютно разными. Чаще всего встречаются горизонтальные и вертикальные варианты расположения элементов навигации. Для создания одноуровневого горизонтального меню воспользуемся следующими стилями:
.menu { /* Сбрасываем браузерные стили */ list-style: none; padding: 0; margin: 0; /* Задаём горизонтальное направление */ display: flex; gap: 16px;}
.menu {
/* Сбрасываем браузерные стили */
list-style: none;
padding: 0;
margin: 0;
/* Задаём горизонтальное направление */
display: flex;
gap: 16px;
}
В примере разметка горизонтального многоуровневого меню базируется на CSS-позиционировании. Всем элементам списка <li> задаётся относительное позиционирование, а вложенному меню <ul> — абсолютное. Первый уровень вложенного меню оставляем без смещения, а для второго установим смещение влево на 100%, чтобы меню прилипало к правой границе первого меню.
/* Первый уровень вложенности */.menu .menu { display: flex; flex-direction: column; gap: 8px; position: absolute; top: 110%; left: 0;}/* Второй уровень вложенности */.menu .menu .menu { top: 0; left: 100%;}
/* Первый уровень вложенности */
.menu .menu {
display: flex;
flex-direction: column;
gap: 8px;
position: absolute;
top: 110%;
left: 0;
}
/* Второй уровень вложенности */
.menu .menu .menu {
top: 0;
left: 100%;
}
Также при создании многоуровневых меню можно часто встретить вариант, когда элементы меню появляются при наведении на них курсора мыши, — по событию hover. В таком случае базовая вёрстка останется аналогичной примеру, только нужно будет доработать стили появления — скрывать вложенное меню по умолчанию свойством display и показывать при наведении мыши.