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 і дозвольте браузеру робити те, що він тихцем умів робити весь цей час.