Блог

Статьи

Как сделать аккордеон на HTML и CSS без JavaScript: элемент details

Аккордеон собирается на элементе details: пункты раскрывает браузер, а атрибут name оставляет открытым один. Показываем код, свою стрелку, плавное раскрытие на CSS и поддержку браузеров.

Аккордеон на HTML и CSS делается без JavaScript на элементе details: заголовок пункта лежит в summary, скрытый текст идёт следом, а раскрывает пункт сам браузер. Чтобы одновременно оставался открытым только один пункт, всем details группы задаём одинаковый атрибут name (Chrome и Edge 120, Firefox 130, Safari 17.2). Плавное раскрытие добавляет CSS с ::details-content, но высоту auto анимируют пока только Chrome и Edge: в остальных браузерах пункт раскрывается сразу.

Коротко

  • Пункт аккордеона - это details с summary первым ребёнком. Клик по summary открывает и закрывает пункт, скрипт не нужен.
  • Пункт открыт, пока у details есть атрибут open. Другие пункты он не трогает.
  • Одинаковый name у нескольких details собирает их в группу: открытие одного закрывает другой. Группа не требует, чтобы один пункт был открыт всегда: по клику на открытый пункт его можно закрыть.
  • Стандартную стрелку убираем через list-style: none и ::-webkit-details-marker, свою рисуем в ::after у summary.
  • Пункт управляется с клавиатуры: на summary переходим клавишей Tab, открываем и закрываем клавишами Enter и Пробел. Мы проверили это в Chrome 154.
  • Плавное раскрытие даёт ::details-content (Chrome и Edge 131, Firefox 143, Safari 18.4) вместе с interpolate-size, которое понимают только Chrome и Edge. Без него аккордеон работает так же, но без анимации.

Как сделать аккордеон на details

Аккордеон - это список пунктов, у каждого есть заголовок, а текст под ним скрыт, пока пункт не открыт. В HTML такой пункт уже есть: элемент details скрывает всё содержимое, кроме summary, и показывает его после клика по заголовку.

<details>
  <summary>Что такое аккордеон?</summary>
  <p>Список пунктов, у каждого заголовок и скрытый текст.</p>
</details>

<details>
  <summary>Нужен ли JavaScript?</summary>
  <p>Нет, пункты раскрывает сам браузер.</p>
</details>

summary ставим первым ребёнком details. Если его не будет, браузер подставит свою подпись, обычно «Details». Всё остальное внутри details - обычная разметка: абзацы, списки, ссылки, формы.

Пункт открыт, пока у details стоит булев атрибут open. Чтобы пункт был открыт при загрузке страницы, пишем <details open>. Браузер сам добавляет и убирает этот атрибут, когда пользователь кликает по заголовку. Чтобы закрыть пункт в разметке, атрибут убираем целиком: запись open="false" пункт откроет, потому что у булева атрибута значение не читается.

О каждом переключении сообщает событие toggle. Оно приходит после смены состояния, и если состояние поменялось несколько раз до отправки, события склеиваются в одно. Для аккордеона на чистом HTML событие не требуется, оно нужно, когда на открытие пункта должен реагировать наш код: например, подгружать данные.

Пример ниже показывает два набора из трёх пунктов. Набор «Без name» состоит из обычных details, набор «С name» получит группу, о ней речь в следующем разделе. Откроем в первом наборе все три пункта: они останутся открытыми одновременно. Если на узком экране наборы расположились друг под другом, это не меняет поведения. Проверить результат просто: откроем второй пункт в наборе «С name», и первый закроется сам.

penlab.space/pen/eEQVCtd0

Как оставить открытым только один пункт

Для аккордеона, где открыт один пункт, у каждого details группы ставим одинаковый name.

<details name="faq" open>
  <summary>Что такое аккордеон?</summary>
  <p>Список пунктов, у каждого заголовок и скрытый текст.</p>
</details>

<details name="faq">
  <summary>Нужен ли JavaScript?</summary>
  <p>Нет, пункты раскрывает сам браузер.</p>
</details>

<details name="faq">
  <summary>Можно открыть сразу два?</summary>
  <p>Нет, при открытии пункта предыдущий закрывается.</p>
</details>

Браузер следит за группой сам. В наборе «С name» предыдущего примера мы открывали пункты по очереди, и в Chrome 154 открытым оставался только последний. Если после этого кликнуть по уже открытому пункту, он закроется, и в группе не будет открытых пунктов: name ограничивает число открытых пунктов сверху, но не требует, чтобы один из них был открыт всегда.

Несколько тонкостей группы:

  • Пункты группы не обязаны идти подряд в разметке: между ними может быть другой код. Группу задаёт значение name, а не соседство.
  • На странице может быть несколько аккордеонов, если у каждого свой name.
  • Если в группе несколько пунктов помечены open, по документации MDN откроется только первый по порядку в разметке. Мы проверили в Chrome 154 запись, где первый пункт закрыт, а второй и третий с open: открытым остался второй.
  • Когда мы открываем пункт кодом, действует то же правило: details.open = true закрывает остальные пункты группы, а для каждого изменённого пункта приходит своё событие toggle.

В браузере без поддержки name атрибут игнорируется. Пункты остаются независимыми, каждый по-прежнему открывается и закрывается. Поддержка у name такая: Chrome и Edge 120, Firefox 130, Safari 17.2.

Как заменить стрелку и оформить заголовок

Браузер рисует перед summary треугольник, маркер раскрытия. По умолчанию summary ведёт себя как элемент списка со значением display: list-item, поэтому маркером управляют так же, как маркером списка: list-style: none его убирает, а ::marker меняет его вид. По документации MDN, треугольник пропадает и при смене display на block. Safari дополнительно показывает маркер через нестандартный ::-webkit-details-marker, и в нём подстраховываем правилом display: none. Свою стрелку рисуем псевдоэлементом ::after.

<section class="faq">
  <details class="faq__item" name="faq" open>
    <summary class="faq__question">Что такое аккордеон?</summary>
    <p class="faq__answer">Список пунктов: у каждого есть заголовок, а текст под ним скрыт, пока пункт не открыт.</p>
  </details>
  <!-- остальные пункты такие же -->
</section>
.faq__question {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  padding: 14px 0;
  cursor: pointer;
  list-style: none;
}

.faq__question::-webkit-details-marker {
  display: none;
}

.faq__question::after {
  content: "";
  flex: none;
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform 0.2s;
}

.faq__item[open] > .faq__question::after {
  transform: rotate(-135deg);
}

.faq__question:focus-visible {
  outline: 2px solid #6aa9ff;
  outline-offset: 2px;
}

Стрелка поворачивается по селектору [open]: браузер сам выставляет этот атрибут, поэтому состояние пункта и вид стрелки не расходятся. Рамку фокуса мы не убираем: без неё человек, который ходит по странице клавишей Tab, не видит, на каком пункте стоит.

С клавиатуры пункт управляется без дополнительного кода. В Chrome 154 мы перешли на заголовок клавишей Tab, Enter открыл пункт, Пробел закрыл его, и второй Пробел открыл снова.

penlab.space/pen/4F8596qm

Внутри summary лучше держать только текст и простую разметку без заголовков. По документации MDN, в части браузеров summary получает роль кнопки, а у вложенного h3 роль заголовка пропадает: скринридер не объявит его как заголовок. Если нужен заголовок в структуре страницы, роль придётся проверять в нужных браузерах и программах чтения.

Что учесть: поиск по странице и ограничения

У details есть ещё одно свойство: текст в закрытом пункте находится поиском по странице. По таблицам совместимости MDN поиск раскрывает пункт с найденным текстом в Chrome и Edge с версии 97, в Firefox с версии 139 и в Safari с версии 26.2. В Firefox до версии 148 и в Safari 26.2 есть ошибка: страница может не прокрутиться к найденному месту.

Есть и границы:

  • Сам по себе details пункт не анимирует: переключение мгновенное. Плавность добавляет CSS из следующего раздела.
  • Если пункту нужно сложное поведение, например подгрузка с сервера или закрытие по условию, понадобится скрипт на событии toggle.
  • Чтобы развернуть или свернуть все пункты одной кнопкой, нужен скрипт со свойством details.open. Пункты с общим name так вместе не откроются: останется открытым один.

Как плавно раскрывать и закрывать пункт

Браузер хранит содержимое details в отдельной части, и CSS получил для неё псевдоэлемент ::details-content. Пока пункт закрыт, у этой части стоит content-visibility: hidden, а при открытии оно меняется на visible. В Chrome 154 мы прочитали это значение у закрытого и открытого пункта через getComputedStyle: hidden и visible. Без перехода содержимое при закрытии исчезает мгновенно, поэтому плавность строится на трёх приёмах:

  • block-size: 0 и overflow: clip у закрытого пункта и block-size: auto у открытого задают, откуда и докуда меняется высота;
  • interpolate-size: allow-keywords разрешает анимировать высоту между числом и ключевым словом auto, как в статье «Плавное появление блока в CSS: как анимировать display: none»;
  • content-visibility в списке переходов с ключевым словом allow-discrete удерживает содержимое видимым, пока идёт закрытие.
.faq__answer {
  margin: 0;
  padding: 0 0 14px;
}

@supports (interpolate-size: allow-keywords) {
  :root {
    interpolate-size: allow-keywords;
  }

  .faq__item::details-content {
    block-size: 0;
    overflow: clip;
    transition: block-size 0.25s ease, content-visibility 0.25s allow-discrete;
  }

  .faq__item[open]::details-content {
    block-size: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .faq__question::after,
  .faq__item::details-content {
    transition: none;
  }
}

Правило .faq__answer с margin: 0 здесь нужно: у абзаца по умолчанию есть внешние поля, и если их оставить, пункт в конце перехода подпрыгивает. В Chrome 154 высота пункта в последних кадрах менялась с 116,5 до 133 пикселей, а с margin: 0 росла плавно до 115. Нижний отступ задаём через padding. В пене к коду добавлены общие стили страницы и рамки между пунктами, на поведение они не влияют.

Мы замерили в Chrome 154 высоту ::details-content у пункта с абзацем в 62 пикселя. При открытии она выросла от 0 до 62 пикселей с промежуточными значениями 18, 48 и 57 пикселей, а при закрытии вернулась к 0, и содержимое оставалось видимым до конца перехода.

Запасной вариант мы вынесли в условие @supports. Поддержка у частей разная: ::details-content понимают Chrome и Edge 131, Firefox 143 и Safari 18.4, а interpolate-size пока только Chrome и Edge 129. В Firefox и Safari условие ложно, блок правил не применяется, и пункт работает как обычный details: открывается и закрывается сразу. Мы имитировали такой случай в Chrome, заменив условие на заведомо ложное: пункт открывался и закрывался сразу, без промежуточных значений высоты. В самих Firefox и Safari мы пример не запускали и опираемся на таблицы совместимости. Для читателей с настройкой «уменьшить движение» блок prefers-reduced-motion отключает переходы.

Ниже итоговый аккордеон: группа через name, своя стрелка, плавное раскрытие и запасной вариант. Чтобы рассмотреть анимацию, откроем пример в редакторе Penlab и поставим в обоих значениях transition у .faq__item::details-content по 1s вместо 0.25s.

penlab.space/pen/HqZjIZvu

Свой аккордеон проще всего собрать в новом пене: он бесплатный, а готовый пример можно отправить ссылкой. Родственный нативный элемент для окон поверх страницы разобран в статье «Как сделать модальное окно на HTML, CSS и JS».

FAQ

Нужен ли JavaScript для аккордеона?

Нет. details открывается и закрывается сам, атрибут name делает аккордеон из одного открытого пункта, а плавность даёт CSS. Скрипт нужен, когда код должен реагировать на открытие пункта: для этого есть событие toggle.

Как открыть пункт по умолчанию?

Ставим атрибут open: <details open>. Если в группе с одним name открыты несколько пунктов, по документации MDN откроется первый по порядку в разметке.

Можно ли закрыть все пункты группы?

Да. Группа с name разрешает не больше одного открытого пункта, но не требует, чтобы один был открыт. В Chrome 154 клик по открытому пункту закрыл его, и в группе не осталось открытых.

Как открыть пункт из JavaScript?

Записываем details.open = true. В группе с name остальные пункты при этом закрываются. Мы проверили в Chrome 154: пункт открылся, предыдущий закрылся, и для каждого пришло событие toggle.

Что будет в старом браузере?

Атрибут name без поддержки игнорируется: пункты работают по отдельности. Блок с ::details-content в нашем коде лежит под @supports, поэтому без поддержки interpolate-size пункт раскрывается без анимации.

Почему открытие плавное, а закрытие мгновенное?

Стоит проверить список переходов: в нём должен быть content-visibility с allow-discrete. По документации MDN, без такого перехода содержимое при закрытии исчезает мгновенно.

Источники