Un acordeon fluid cu zero JavaScript
Toată lumea recurge la o stare React și la un hook de măsurare a înălțimii ca să construiască un acordeon. Browserul livrează unul nativ de ani buni — iar din 2024 se animează la deschidere și la închidere cu CSS pur. Iată tehnica completă: details[name], ::details-content, interpolate-size și proprietatea care face ca și închiderea să se anime.
Acordeonul este unul dintre cele mai reimplementate widget-uri de pe web. Rețeta obișnuită: o bucată de stare React pentru ce este deschis, un onClick care îl comută și niște CSS (sau un hook de măsurare a înălțimii) pentru a anima dezvăluirea. Funcționează, dar transformă un conținut static într-o componentă client care livrează și hidratează JavaScript — doar pentru a deschide și a închide.
Browserul are un element de acordeon nativ de ani de zile: <details>. Singura lui slăbiciune istorică — nu îl puteai anima — a dispărut din 2024. Acest articol construiește un acordeon complet animat și exclusiv, cu un chevron care se rotește și zero JavaScript: fără stare, fără hooks, fără hidratare.
Acordeonul pe care îl livrează toată lumea
Iată tiparul în forma lui cea mai des întâlnită — stare React pentru elementul deschis, un handler de clic care îl comută și trucul grid-template-rows: 0fr → 1fr pentru a anima înălțimea fără a măsura nimic:
"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; }E în regulă, iar trucul cu grid-ul este cu adevărat ingenios. Dar observă ce costă: acel "use client" din vârf. Această componentă se descarcă, se parsează și se hidratează acum pe fiecare pagină pe care apare — și face toate acestea pentru a gestiona un singur boolean. Pentru un FAQ pur prezentațional care nu a avut niciodată nevoie să fie interactiv în sens React, este pură povară suplimentară.
Elementul nativ pe care l-ai uitat
<details> și <summary> sunt un widget de dezvăluire integrat. Browserul se ocupă de starea deschis/închis, de interacțiunea prin clic și tastatură și de accesibilitate — gratuit:
<details>
<summary>What does it cost?</summary>
<p>Nothing — it's a native browser element.</p>
</details>Acesta este un acordeon funcțional fără JavaScript și fără CSS. Dă clic pe rezumat (sau apasă Enter pe el) și se comută. Problema, ani la rând, a fost că se comuta instantaneu — nu exista nicio modalitate de a anima dezvăluirea, exact motivul pentru care echipele recurgeau la versiunea JavaScript.
De ce nu se putea anima — până de curând
Motivul este subtil. Când un <details> este închis, browserul nu doar ascunde vizual conținutul — îl scoate complet din randare (o ascundere de tip content-visibility). Nu poți face tranziție către sau dinspre „nerandat”, iar multă vreme nu puteai face nici tranziție a lui height către sau dinspre auto. Așadar nu exista nimic între care să animezi.
Unii au încercat același truc cu grid-ul pe care îl folosește versiunea JavaScript, mutându-l pe atributul [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. */Arată corect și chiar animează deschiderea. Dar eșuează la închidere: în clipa în care atributul [open] este eliminat, browserul ascunde conținutul imediat, așa că restrângerea nu are niciodată ocazia să se anime — pur și simplu se închide brusc. O jumătate de soluție — asta a ținut pe toată lumea pe JavaScript.
Soluția: ::details-content + interpolate-size
Trei funcționalități CSS apărute în 2024 rezolvă problema împreună. Cheia este un nou pseudo-element, ::details-content, care vizează exact partea pliabilă a unui <details> — astfel încât o poți stiliza și anima direct:
: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;
}Citite ca un tot, sunt trei lucruri care cooperează:
::details-contentîți oferă acces la regiunea de conținut ascunsă, pe care înainte nu o puteai selecta deloc.interpolate-size: allow-keywordspermite animarea luiblock-sizeîntre0și înălțimea intrinsecăauto— lucrul pe care CSS l-a refuzat dintotdeauna.transition-behavior: allow-discrete(prin listarea luicontent-visibilityîn tranziție) menține conținutul randat pe tot parcursul restrângerii, așa că închiderea se animează în loc să se închidă brusc.
Împreună animează ambele sensuri — deschidere și închidere — înălțimea trecând lin către și dinspre dimensiunea naturală a conținutului. Fără măsurare, fără hook, fără stare.
Acordeon exclusiv, cu un singur atribut
Majoritatea acordeoanelor vor „doar unul deschis odată”. În versiunea JavaScript, pentru asta există starea openId. Nativ, este un singur atribut: dă fiecărui <details> din grup același name, iar browserul impune exclusivitatea — deschiderea unuia le închide pe celelalte:
<!-- 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>O rezervă utilă de știut: name definește un grup exclusiv la nivelul întregii pagini, așa că, dacă afișezi două acordeoane independente, dă fiecăruia propriul name unic — altfel deschiderea unui element din unul va închide un element din celălalt.
Chevronul, fără JavaScript
Ultima piesă pentru care lumea recurge la JS este micuța săgeată care se răstoarnă când secțiunea se deschide. Este doar o transformare legată de atributul [open] — browserul setează acel atribut, așa că rotația este CSS pur. Profită de ocazie și ascunde și triunghiul de dezvăluire implicit:
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 */
}Punem totul cap la cap
Iată totul — un element de acordeon exclusiv și animat, cu un chevron care se rotește și un spațiu între întrebare și răspuns:
<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 */Aceasta este funcționalitatea completă. Deschidere/închidere, suport pentru tastatură, accesibilitate, exclusivitate, o animație fluidă în ambele sensuri și un chevron care se răstoarnă — și nu livrează niciun octet de JavaScript. Se randează ca HTML static pe server și nu are nevoie de nimic pe client ca să funcționeze.
Suportul browserelor
::details-content, interpolate-size și allow-discrete sunt recente (Chromium 129–131, Safari 18.x, Firefox urmând). Sună riscant, dar degradarea este de cea mai bună calitate — nu ai nevoie deloc de @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. */Pe un browser care nu înțelege aceste reguli, <details> tot se deschide și se închide corect; doar că o face instantaneu, fără animație. Animația este o îmbunătățire progresivă suprapusă peste un element care funcționează deja peste tot — așa că nu există niciun fallback de scris și nimic de stricat.
Comportamentul — deschidere, închidere, tastatură, accesibilitate, exclusivitate — este nativ și funcționează peste tot. Animația este CSS pur deasupra. Dacă un browser este prea vechi pentru animație, acordeonul funcționează în continuare; doar că nu are tranziția lină.
CSS versus JavaScript, sincer
| Aspect | Acordeon JS (stare + hook) | <code><details></code> nativ + CSS |
|---|---|---|
| Deschidere / închidere | Stare React + onClick | Integrat în browser |
| JavaScript livrat | Da (componentă client, se hidratează) | Niciunul |
| Animație fluidă | Da (grid / înălțime măsurată) | Da (::details-content) |
| Doar unul deschis | Stare manuală (openId) | Atributul name |
| Tastatură + a11y | Îl cablezi tu (sau o bibliotecă) | Nativ |
| Funcționează înainte de hidratare / cu JS dezactivat | Nu | Da |
| Răsturnarea chevronului | Clasă comutată prin JS | CSS pe [open] |
JavaScript câștigă în continuare atunci când conținutul panoului este cu adevărat dinamic — încărcat la cerere, condus de starea aplicației, sau când deschiderea trebuie să declanșeze efecte secundare pe care le controlezi în JS. Dar pentru cazul de departe cel mai frecvent — un FAQ prezentațional sau o listă de dezvăluire — elementul nativ egalează versiunea JS funcționalitate cu funcționalitate, fără nimic din bundle.
De reținut
- Acordeonul JavaScript pe care îl scrie toată lumea există mai ales pentru că
<details>nativ nu se putea anima — o limitare acum dispărută. ::details-content+interpolate-size: allow-keywordsanimează înălțimea între0șiauto.- Trucul care face ca închiderea să se anime (nu doar deschiderea) este
transition-behavior: allow-discretepecontent-visibility. <details name="…">îți oferă un grup exclusiv „unul deschis odată” cu zero JavaScript — folosește un nume unic pentru fiecare acordeon.- Răsturnarea chevronului este o
transformCSS legată de atributul[open]. - Se degradează grațios fără
@supports: browserele vechi deschid/închid instantaneu, iar animația este o pură îmbunătățire.
Niciuna dintre aceste piese nu este exotică — <details> are decenii, iar funcționalitățile de animație sunt CSS banal și bine specificat. Schimbarea constă în a înțelege că nu mai trebuie să recurgi la o stare React ca să obții un acordeon rafinat. Șterge componenta client, livrează HTML static și lasă browserul să facă ceea ce a putut face discret dintotdeauna.