Задача
СкопированоИногда встречается задача: создать выпадающее меню, которое будет плавно раскрываться. В этом рецепте будет решение на чистом CSS. Вся магия кроется в единицах измерения lh
. Будем менять высоту строки, тем самым добившись эффекта плавного раскрытия меню.
Готовое решение
СкопированоРазметка в этой ситуации не играет особой роли. Вы можете адаптировать её под свои конкретные задачи. Возьмём простой вариант с кнопкой и списком ссылок.
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list" > Меню </button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul> </div>
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list" > Меню </button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul> </div>
Вся магия будет реализована в CSS. Тут готовый код, а полный разбор стилей будет ниже.
.button { inline-size: 100%; padding: 0.5lh 1.5lh; font: inherit; color: currentColor; background-color: #f28482; border: none; cursor: pointer;}.button:hover,.button:focus-visible { background-color: #f5cac3;}.menu { position: relative; display: grid; margin-block-start: 0.5lh; background-color: #f28482; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s;}.menu-item { overflow: hidden;}.menu-link { display: block; padding: 0.5lh 80px;}.menu-link:hover,.menu-link:focus-visible { background-color: #f5cac3;}.button.active ~ .menu { line-height: 1.2; color: currentColor;}
.button { inline-size: 100%; padding: 0.5lh 1.5lh; font: inherit; color: currentColor; background-color: #f28482; border: none; cursor: pointer; } .button:hover, .button:focus-visible { background-color: #f5cac3; } .menu { position: relative; display: grid; margin-block-start: 0.5lh; background-color: #f28482; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s; } .menu-item { overflow: hidden; } .menu-link { display: block; padding: 0.5lh 80px; } .menu-link:hover, .menu-link:focus-visible { background-color: #f5cac3; } .button.active ~ .menu { line-height: 1.2; color: currentColor; }
JavaScript в этом примере будет только добавлять и удалять класс кнопке по клику или нажатию на Enter, а ещё менять значения атрибута aria
.
const button = document.querySelector('.button')const menu = document.querySelector('.menu')const menuLinks = document.querySelectorAll('.menu-link')button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) }})
const button = document.querySelector('.button') const menu = document.querySelector('.menu') const menuLinks = document.querySelectorAll('.menu-link') button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) } })
Разбор решения
СкопированоЧаще всего, чтобы раскрыть выпадающее меню плавно, используют JavaScript для расчёта конечной высоты элемента. Это нужно, чтобы анимировать переход между height
и рассчитанной конечной высотой в пикселях.
Обойдёмся без лишних усилий только CSS и его современными возможностями.
Разметка
СкопированоДля начала разберём простую разметку примера. Она может быть какой угодно, в зависимости от решаемой вами задачи.
Это могут быть вложенные списки, <details>
или любой другой элемент. Для демонстрации нам достаточно кнопки и списка со ссылками, который и будет являться выпадающим меню.
Для раскрытия меню лучше всего использовать кнопку <button>
. Браузер «из коробки» поддерживает нужные сценарии взаимодействия с этим интерактивным элементом, а скринридеры правильно объявят пользователю, что это кнопка. С семантикой тоже всё в порядке. Создадим кнопку без наворотов, нам понадобиться только класс и два ARIA-атрибута — aria
и aria
. Благодаря aria
вспомогательные технологии расскажут, что список со ссылками свёрнут или развёрнут, а aria
свяжет для них кнопку и список на уровне разметки.
<button class="button" aria-expanded="false" aria-controls="list"> Меню</button>
<button class="button" aria-expanded="false" aria-controls="list" > Меню </button>
Ниже разместим список со ссылками для меню. Важно чтобы кнопка и меню шли в разметке друг за другом, в стилях используется селектор последующего элемента.
<ul class="menu" id="list"> <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>
<ul class="menu" id="list"> <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>
Обернём кнопку и меню в общего родителя исключительно в оформительских целях, чтобы разместить элементы по центру страницы. Обёртка не влияет на работу меню.
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list" > Меню </button> <ul class="menu" id="list"> <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></div>
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list" > Меню </button> <ul class="menu" id="list"> <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> </div>
JavaScript
СкопированоЧтобы всё заработало, понадобится пара строк JavaScript-кода. По клику на кнопку к ней должен добавляться класс .active
, а по второму клику убираться. Конечно, само имя класса можно изменять, но не забудьте поменять его не только в скрипте, но и в стилях. Он важен для работы.
Находим в разметке нужный элемент при помощи .query
и добавляем обработчик события клика с помощью .add
.
Для переключения класса туда-сюда отлично подходит метод class
. А тернарный оператор поможет переключать значения aria
с true
на false
и обратно в зависимости от наличия класса у кнопки.
const button = document.querySelector('.button')button.addEventListener('click', (e) => { e.target.classList.toggle('active') e.target.setAttribute( 'aria-expanded', e.target.classList.contains('active') ? 'true' : 'false' )})
const button = document.querySelector('.button') button.addEventListener('click', (e) => { e.target.classList.toggle('active') e.target.setAttribute( 'aria-expanded', e.target.classList.contains('active') ? 'true' : 'false' ) })
Стили
СкопированоВ текущем решении используем единицу измерения lh
, которая зависит от текущей высоты строки — свойства line
. В закрытом состоянии у меню будет нулевая высота строки, а в открытом — 1.2.
1
— это значение по умолчанию для этого свойства. Браузер его применит, если не задано другое. Обязательно указывайте для line
именно числовое значение. К сожалению, ключевые слова типа initial
не дадут нужного эффекта.
Из всех стилей примера для желаемого эффекта важны вот эти строчки:
.menu { margin-block-start: 0.5lh; overflow: hidden; line-height: 0; transition: line-height 0.5s;}.button.active ~ .menu { line-height: 1.2;}
.menu { margin-block-start: 0.5lh; overflow: hidden; line-height: 0; transition: line-height 0.5s; } .button.active ~ .menu { line-height: 1.2; }
В дефолтном состоянии у .menu
высота строки равно 0. А если у кнопки .button
появляется класс .active
, то следующему за ним .menu
задаётся высота строки 1.2.
Важно задать для .menu
overflow
, чтобы в закрытом состоянии не был виден текст пунктов меню.
Верхний отступ тоже задан в lh
, чтобы он плавно вырастал вместе с меню. Но это дело вкуса.
Для плавности используется свойство transition
. С его помощью высота строк меняется не резко, а плавно, за пол секунды.
Сейчас, при закрытии, строки текста наезжают друг на друга и получается грязно. Добавим изменения цвета текста с transparent
на current
— цвет, заданный родителю. Не забудем указать в свойстве transition
, что color
тоже должен меняться за 0.5 секунды. Тогда текст появляется и исчезает плавно вместе с открытием и закрытием меню. Чистота и красота!
.menu { margin-block-start: 0.5lh; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s;}.button.active ~ .menu { line-height: 1.2; color: currentColor;}
.menu { margin-block-start: 0.5lh; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s; } .button.active ~ .menu { line-height: 1.2; color: currentColor; }
Сейчас, даже если меню закрыто, на ссылки из него можно попасть при помощи Tab. Это не лучшее поведение. Нужно «скрывать» меню от клавиатурной навигации, не только визуально.
Для этого используем атрибуты aria
для .menu
и tabindex
для каждой ссылки. В закрытом состоянии значения будут true
и -1
соответственно. Таким образом скринридеры не зачитают содержимое раскрывающегося меню, а на ссылки нельзя будет попасть с клавиатуры.
В открытом состоянии будем менять значения на fale
и 0
с помощью JavaScript, делая меню доступным для клавиатуры и скринридеров.
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list">Меню</button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul></div>
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list">Меню</button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul> </div>
const button = document.querySelector('.button')const menu = document.querySelector('.menu')const menuLinks = document.querySelectorAll('.menu-link')button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) }})
const button = document.querySelector('.button') const menu = document.querySelector('.menu') const menuLinks = document.querySelectorAll('.menu-link') button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) } })
Финальный код
Скопировано<div class="container"> <button class="button" aria-expanded="false" aria-controls="list">Меню</button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul></div>
<div class="container"> <button class="button" aria-expanded="false" aria-controls="list">Меню</button> <ul class="menu" id="list" aria-hidden="true"> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Винни-Пух</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Тигра</a> </li> <li class="menu-item"> <a href="#" class="menu-link" tabindex="-1">Пятачок</a> </li> </ul> </div>
.button { inline-size: 100%; padding: 0.5lh 1.5lh; font: inherit; color: currentColor; background-color: #f28482; border: none; cursor: pointer;}.button:hover,.button:focus-visible { background-color: #f5cac3;}.menu { position: relative; display: grid; margin-block-start: 0.5lh; background-color: #f28482; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s;}.menu-item { overflow: hidden;}.menu-link { display: block; padding: 0.5lh 80px;}.menu-link:hover,.menu-link:focus-visible { background-color: #f5cac3;}.button.active ~ .menu { line-height: 1.2; color: currentColor;}
.button { inline-size: 100%; padding: 0.5lh 1.5lh; font: inherit; color: currentColor; background-color: #f28482; border: none; cursor: pointer; } .button:hover, .button:focus-visible { background-color: #f5cac3; } .menu { position: relative; display: grid; margin-block-start: 0.5lh; background-color: #f28482; overflow: hidden; line-height: 0; color: transparent; transition: line-height 0.5s, color 0.5s; } .menu-item { overflow: hidden; } .menu-link { display: block; padding: 0.5lh 80px; } .menu-link:hover, .menu-link:focus-visible { background-color: #f5cac3; } .button.active ~ .menu { line-height: 1.2; color: currentColor; }
const button = document.querySelector('.button')const menu = document.querySelector('.menu')const menuLinks = document.querySelectorAll('.menu-link')button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) }})
const button = document.querySelector('.button') const menu = document.querySelector('.menu') const menuLinks = document.querySelectorAll('.menu-link') button.addEventListener('click', (e) => { button.classList.toggle('active') if (button.classList.contains('active')) { button.setAttribute('aria-expanded', 'true') menu.setAttribute('aria-hidden', 'false') menuLinks.forEach(link => link.setAttribute('tabindex', '0')) } else { button.setAttribute('aria-expanded', 'false') menu.setAttribute('aria-hidden', 'true') menuLinks.forEach(link => link.setAttribute('tabindex', '-1')) } })