Плавный аккордеон без единой строчки JavaScript
Все хватаются за состояние React и хук для измерения высоты, чтобы собрать аккордеон. А браузер уже годами имеет нативный — и начиная с 2024 года он анимирует открытие и закрытие чистым CSS. Вот полная техника: details[name], ::details-content, interpolate-size и то единственное свойство, которое анимирует и закрытие тоже.
Аккордеон — один из наиболее часто переписываемых виджетов в вебе. Привычный рецепт: кусочек состояния React для того, что открыто, onClick для переключения и немного CSS (или хук для измерения высоты), чтобы анимировать раскрытие. Оно работает, но превращает кусок статического контента в клиентский компонент, который доставляет и гидратирует JavaScript — и всё лишь ради открытия и закрытия.
У браузера уже годами есть нативный элемент-аккордеон: <details>. Его единственная историческая слабость — его нельзя было анимировать — исчезла по состоянию на 2024 год. В этой статье мы соберём полностью анимированный, эксклюзивный аккордеон с вращающимся шевроном и без единой строчки JavaScript: без состояния, без хуков, без гидратации.
Аккордеон, который доставляют все
Вот этот паттерн в самой распространённой форме — состояние React для открытого элемента, обработчик клика для переключения и трюк grid-template-rows: 0fr → 1fr, чтобы анимировать высоту, ничего не измеряя:
"use client"; // ← the whole component is now a client bundle
import { useState } from "react";
export function Accordion({ items }: { items: Item[] }) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div>
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<button onClick={() => setOpenId(isOpen ? null : item.id)}>
{item.title}
</button>
{/* grid 0fr → 1fr is the CSS trick that animates the height */}
<div className="body" data-open={isOpen}>
<div>{item.content}</div>
</div>
</div>
);
})}
</div>
);
}.body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }Оно нормальное, а grid-трюк действительно хитроумный. Но обратите внимание, чего это стоит: "use client" наверху. Теперь этот компонент загружается, парсится и гидратируется на каждой странице, где появляется, — и делает всё это ради управления одним булевым значением. Для презентационного FAQ, которому никогда не нужно было быть интерактивным в смысле React, это чистые накладные расходы.
Нативный элемент, о котором вы забыли
<details> и <summary> — это встроенный виджет раскрытия. Браузер сам управляет состоянием открыто/закрыто, обработкой клика и клавиатуры и доступностью — бесплатно:
<details>
<summary>What does it cost?</summary>
<p>Nothing — it's a native browser element.</p>
</details>Это рабочий аккордеон без JavaScript и без CSS. Кликните по summary (или нажмите Enter) — и он переключится. Проблема годами заключалась в том, что он переключался мгновенно — не было способа анимировать раскрытие, и именно поэтому команды вместо этого хватались за JavaScript-версию.
Почему его нельзя было анимировать — до недавнего времени
Причина тонкая. Когда <details> закрыт, браузер не просто визуально прячет его контент — он полностью исключает контент из рендеринга (сокрытие в стиле content-visibility). Нельзя сделать переход к состоянию «не отрендерено» или от него, а ещё долгое время нельзя было сделать переход height к auto или от него. Так что не было ничего, между чем анимировать.
Люди пробовали тот же grid-трюк, который использует JavaScript-версия, перенося его на атрибут [open]:
/* Looks like it should work — but only animates OPENING. */
details > .body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
so the collapse never animates — it just snaps shut. */Выглядит правильно, и оно даже анимирует открытие. Но оно ломается на закрытии: как только атрибут [open] убран, браузер мгновенно прячет контент, так что сворачивание так и не получает шанса анимироваться — оно просто резко захлопывается. Половина решения — вот что держало всех на JavaScript.
Решение: ::details-content + interpolate-size
Три возможности CSS, появившиеся в 2024 году, решают это вместе. Ключ — новый псевдоэлемент ::details-content, который целится именно в сворачиваемую часть <details>, — так что вы можете стилизовать и анимировать её напрямую:
:root {
/* lets height/block-size transition to and from the 'auto' keyword */
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
overflow: clip;
transition:
block-size 300ms ease,
/* keeps the content rendered while it collapses, instead of vanishing */
content-visibility 300ms allow-discrete;
}
details[open]::details-content {
block-size: auto;
}Если читать как единое целое, это три вещи, работающие сообща:
::details-contentдаёт вам зацепку за скрытую область контента, которую раньше вообще нельзя было выделить.interpolate-size: allow-keywordsпозволяет анимироватьblock-sizeмежду0и собственной высотойauto— именно то, что CSS исторически отказывался делать.transition-behavior: allow-discrete(через перечислениеcontent-visibilityв переходе) удерживает контент отрендеренным на протяжении сворачивания, так что закрытие анимируется, а не резко захлопывается.
Вместе они анимируют оба направления — открытие и закрытие — с высотой, плавно сглаживающейся к естественному размеру контента и от него. Без измерений, без хука, без состояния.
Эксклюзивный аккордеон — одним атрибутом
Большинство аккордеонов хотят «только один открыт за раз». В JavaScript-версии именно для этого предназначено состояние openId. Нативно — это один атрибут: дайте каждому <details> в группе одинаковое name, и браузер обеспечит эксклюзивность — открытие одного закрывает остальные:
<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>Одна оговорка, которую стоит знать: name определяет эксклюзивную группу на всю страницу, так что если вы рендерите два независимых аккордеона, дайте каждому собственное уникальное name — иначе открытие элемента в одном закроет элемент в другом.
Шеврон — без единой строчки JavaScript
Последняя деталь, ради которой люди хватаются за JS, — маленькая стрелочка, которая переворачивается, когда секция открывается. Это просто трансформация, привязанная к атрибуту [open] — браузер сам выставляет этот атрибут, так что вращение является чистым CSS. Заодно спрячьте и стандартный треугольник раскрытия:
summary {
list-style: none; /* remove the default triangle marker */
cursor: pointer;
}
summary::-webkit-details-marker { display: none; } /* Safari */
summary .chevron {
transition: transform 200ms ease;
}
details[open] summary .chevron {
transform: rotate(180deg); /* flip on open — pure CSS, no JS */
}Собираем всё воедино
Вот всё вместе — эксклюзивный анимированный элемент аккордеона с вращающимся шевроном и отступом между вопросом и ответом:
<details name="faq" class="item">
<summary>
<h3>How does it work?</h3>
<svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
</summary>
<div class="answer">
<p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
</div>
</details>:root { interpolate-size: allow-keywords; }
.item::details-content {
block-size: 0;
overflow: clip;
transition:
block-size 300ms ease,
content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }
.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; } /* gap between question and answer */Это полноценная фича. Открытие/закрытие, поддержка клавиатуры, доступность, эксклюзивность, плавная двусторонняя анимация и переворачивающийся шеврон — и оно не доставляет ни единого байта JavaScript. Оно рендерится как статический HTML на сервере и ничего не требует на клиенте, чтобы работать.
Поддержка браузерами
::details-content, interpolate-size и allow-discrete — свежие (Chromium 129–131, Safari 18.x, Firefox догоняет). Это звучит рискованно, но деградация здесь наилучшего сорта — вам вообще не нужен @supports:
/* No @supports needed: browsers that don't know ::details-content simply
ignore these rules. The <details> still opens and closes — just instantly.
The animation is a progressive enhancement, never a requirement. */В браузере, который не понимает этих правил, <details> всё равно корректно открывается и закрывается; он просто делает это мгновенно, без анимации. Анимация — это прогрессивное улучшение, наложенное поверх элемента, который уже работает везде, — так что нет ни запасного варианта, который надо писать, ни чего ломать.
Поведение — открытие, закрытие, клавиатура, доступность, эксклюзивность — нативное и работает везде. Анимация — чистый CSS поверх. Если браузер слишком стар для анимации, аккордеон всё равно работает; он просто не сглаживается.
CSS против JavaScript — честно
| Аспект | JS-аккордеон (состояние + хук) | Нативный <code><details></code> + CSS |
|---|---|---|
| Открытие / закрытие | Состояние React + onClick | Встроено в браузер |
| Доставленный JavaScript | Да (клиентский компонент, гидратируется) | Нет |
| Плавная анимация | Да (grid / измеренная высота) | Да (::details-content) |
| Только один открыт | Ручное состояние (openId) | Атрибут name |
| Клавиатура + a11y | Вы это подключаете (или библиотека) | Нативно |
| Работает до гидратации / с выключенным JS | Нет | Да |
| Переворот шеврона | Класс, переключаемый через JS | CSS на [open] |
JavaScript всё ещё выигрывает, когда контент панели по-настоящему динамический — загружается по требованию, управляется состоянием приложения, или когда открытие должно триггерить побочные эффекты, которыми вы управляете в JS. Но для преимущественно распространённого случая — презентационный FAQ или список раскрытия — нативный элемент повторяет JS-версию функция за функцией, без всякого бандла.
Главное
- JavaScript-аккордеон, который пишет каждый, существует в основном потому, что нативный
<details>нельзя было анимировать, — ограничение, которого уже нет. ::details-content+interpolate-size: allow-keywordsанимируют высоту между0иauto.- Трюк, благодаря которому анимируется закрытие (а не только открытие), — это
transition-behavior: allow-discreteнаcontent-visibility. <details name="…">даёт вам эксклюзивную группу «один открыт за раз» без единой строчки JavaScript — используйте уникальное name на каждый аккордеон.- Переворот шеврона — это CSS-
transform, привязанный к атрибуту[open]. - Оно деградирует изящно без всякого
@supports: старые браузеры открывают/закрывают мгновенно, а анимация — чистое улучшение.
Ни одна из этих частей не является экзотической — <details> имеет десятки лет, а возможности анимации — это скучный, хорошо специфицированный CSS. Сдвиг в том, чтобы осознать: вам больше не нужно хвататься за состояние React, чтобы получить отполированный аккордеон. Удалите клиентский компонент, доставляйте статический HTML и позвольте браузеру делать то, что он втихаря умел делать всё это время.