Skip to main content
Назад к блогу
CSSHTMLAccordionFrontendPerformance

Плавный аккордеон без единой строчки JavaScript

Все хватаются за состояние React и хук для измерения высоты, чтобы собрать аккордеон. А браузер уже годами имеет нативный — и начиная с 2024 года он анимирует открытие и закрытие чистым CSS. Вот полная техника: details[name], ::details-content, interpolate-size и то единственное свойство, которое анимирует и закрытие тоже.

Опубликовано 28 июля 2026 г.9 мин чтения

Аккордеон — один из наиболее часто переписываемых виджетов в вебе. Привычный рецепт: кусочек состояния React для того, что открыто, onClick для переключения и немного CSS (или хук для измерения высоты), чтобы анимировать раскрытие. Оно работает, но превращает кусок статического контента в клиентский компонент, который доставляет и гидратирует JavaScript — и всё лишь ради открытия и закрытия.

У браузера уже годами есть нативный элемент-аккордеон: <details>. Его единственная историческая слабость — его нельзя было анимировать — исчезла по состоянию на 2024 год. В этой статье мы соберём полностью анимированный, эксклюзивный аккордеон с вращающимся шевроном и без единой строчки JavaScript: без состояния, без хуков, без гидратации.

Аккордеон, который доставляют все

Вот этот паттерн в самой распространённой форме — состояние React для открытого элемента, обработчик клика для переключения и трюк grid-template-rows: 0fr → 1fr, чтобы анимировать высоту, ничего не измеряя:

Accordion.tsx
"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>&lt;details&gt;</code> + CSS
Открытие / закрытиеСостояние React + onClickВстроено в браузер
Доставленный JavaScriptДа (клиентский компонент, гидратируется)Нет
Плавная анимацияДа (grid / измеренная высота)Да (::details-content)
Только один открытРучное состояние (openId)Атрибут name
Клавиатура + a11yВы это подключаете (или библиотека)Нативно
Работает до гидратации / с выключенным JSНетДа
Переворот шевронаКласс, переключаемый через JSCSS на [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 и позвольте браузеру делать то, что он втихаря умел делать всё это время.