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