Un accordéon fluide avec zéro JavaScript
Tout le monde se rue sur un état React et un hook de mesure de hauteur pour construire un accordéon. Le navigateur en propose un nativement depuis des années — et depuis 2024, il s'anime à l'ouverture comme à la fermeture en CSS pur. Voici la technique complète : details[name], ::details-content, interpolate-size, et la propriété qui rend aussi la fermeture animée.
L'accordéon est l'un des composants les plus réimplémentés du web. La recette habituelle : un état React pour savoir ce qui est ouvert, un onClick pour le basculer, et un peu de CSS (ou un hook de mesure de hauteur) pour animer l'apparition. Ça marche, mais cela transforme un contenu statique en composant client qui embarque et hydrate du JavaScript — juste pour ouvrir et fermer.
Le navigateur possède un élément accordéon natif depuis des années : <details>. Sa seule faiblesse historique — impossible de l'animer — a disparu depuis 2024. Cet article construit un accordéon entièrement animé et exclusif, avec un chevron qui pivote et zéro JavaScript : pas d'état, pas de hooks, pas d'hydratation.
L'accordéon que tout le monde livre
Voici le motif dans sa forme la plus courante — un état React pour l'élément ouvert, un gestionnaire de clic pour le basculer, et l'astuce grid-template-rows: 0fr → 1fr pour animer la hauteur sans rien mesurer :
"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; }C'est correct, et l'astuce de la grille est réellement astucieuse. Mais notez ce qu'elle coûte : le "use client" tout en haut. Ce composant est désormais téléchargé, analysé et hydraté sur chaque page où il apparaît — et il fait tout cela pour gérer un unique booléen. Pour une FAQ purement présentationnelle qui n'a jamais eu besoin d'être interactive au sens React, c'est du surcoût pur.
L'élément natif que vous avez oublié
<details> et <summary> forment un widget de divulgation intégré. Le navigateur gère l'état ouvert/fermé, les interactions au clic et au clavier, ainsi que l'accessibilité — gratuitement :
<details>
<summary>What does it cost?</summary>
<p>Nothing — it's a native browser element.</p>
</details>Voilà un accordéon fonctionnel sans JavaScript ni CSS. Cliquez sur le résumé (ou appuyez sur Entrée) et il bascule. Le hic, pendant des années, c'est qu'il basculait instantanément — impossible d'animer l'apparition, et c'est précisément pour cela que les équipes se rabattaient sur la version JavaScript.
Pourquoi il ne pouvait pas s'animer — jusqu'à récemment
La raison est subtile. Lorsqu'un <details> est fermé, le navigateur ne se contente pas de masquer visuellement son contenu — il le retire complètement du rendu (un masquage de type content-visibility). On ne peut pas faire de transition vers ou depuis « non rendu », et pendant longtemps on ne pouvait pas non plus faire de transition de height vers ou depuis auto. Il n'y avait donc rien entre quoi animer.
Certains ont tenté la même astuce de grille que la version JavaScript, en la déplaçant sur l'attribut [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. */Ça semble correct, et cela anime même l'ouverture. Mais cela échoue à la fermeture : dès que l'attribut [open] est retiré, le navigateur masque le contenu immédiatement, si bien que le repli n'a jamais l'occasion de s'animer — il claque simplement. Une demi-solution, voilà ce qui maintenait tout le monde sur JavaScript.
La solution : ::details-content + interpolate-size
Trois fonctionnalités CSS arrivées en 2024 résolvent le problème ensemble. La clé est un nouveau pseudo-élément, ::details-content, qui cible exactement la partie repliable d'un <details> — on peut donc la styliser et l'animer directement :
: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;
}Lu comme un tout, c'est trois choses qui coopèrent :
::details-contentvous donne prise sur la région de contenu masquée, que vous ne pouviez auparavant pas sélectionner du tout.interpolate-size: allow-keywordsautorise l'animation deblock-sizeentre0et la hauteur intrinsèqueauto— ce que le CSS a historiquement refusé de faire.transition-behavior: allow-discrete(en listantcontent-visibilitydans la transition) maintient le contenu rendu pendant le repli, si bien que la fermeture s'anime au lieu de claquer.
Ensemble, ils animent les deux sens — ouverture et fermeture — la hauteur s'assouplissant en douceur vers et depuis la taille naturelle du contenu. Pas de mesure, pas de hook, pas d'état.
Accordéon exclusif, avec un seul attribut
La plupart des accordéons veulent « un seul ouvert à la fois ». Dans la version JavaScript, c'est le rôle de l'état openId. Nativement, c'est un simple attribut : donnez à chaque <details> du groupe le même name, et le navigateur impose l'exclusivité — en ouvrir un ferme les autres :
<!-- 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>Une réserve à connaître : name définit un groupe exclusif à l'échelle de toute la page ; donc si vous affichez deux accordéons indépendants, donnez à chacun son propre name unique — sinon ouvrir un élément dans l'un fermera un élément dans l'autre.
Le chevron, sans JavaScript
La dernière pièce pour laquelle on se tourne vers JS est la petite flèche qui pivote quand la section s'ouvre. Ce n'est qu'une transformation liée à l'attribut [open] — c'est le navigateur qui pose cet attribut, donc la rotation est du CSS pur. Profitez-en aussi pour masquer le triangle de divulgation par défaut :
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 */
}On assemble le tout
Voici l'ensemble — un élément d'accordéon exclusif et animé, avec un chevron qui pivote et un espace entre la question et la réponse :
<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 */Voilà la fonctionnalité complète. Ouverture/fermeture, prise en charge du clavier, accessibilité, exclusivité, une animation fluide dans les deux sens et un chevron qui bascule — et cela ne livre pas un seul octet de JavaScript. Elle s'affiche sous forme de HTML statique côté serveur et n'a besoin de rien côté client pour fonctionner.
Prise en charge par les navigateurs
::details-content, interpolate-size et allow-discrete sont récents (Chromium 129–131, Safari 18.x, Firefox à suivre). Cela paraît risqué, mais la dégradation est de la meilleure espèce — vous n'avez pas du tout besoin 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. */Sur un navigateur qui ne comprend pas ces règles, le <details> s'ouvre et se ferme toujours correctement ; il le fait simplement instantanément, sans l'animation. L'animation est une amélioration progressive posée par-dessus un élément qui fonctionne déjà partout — il n'y a donc aucun repli à écrire et rien à casser.
Le comportement — ouverture, fermeture, clavier, accessibilité, exclusivité — est natif et fonctionne partout. L'animation est du CSS pur par-dessus. Si un navigateur est trop ancien pour l'animation, l'accordéon fonctionne quand même ; il n'y a simplement pas d'assouplissement.
CSS ou JavaScript, honnêtement
| Enjeu | Accordéon JS (état + hook) | <code><details></code> natif + CSS |
|---|---|---|
| Ouverture / fermeture | État React + onClick | Intégré au navigateur |
| JavaScript livré | Oui (composant client, hydrate) | Aucun |
| Animation fluide | Oui (grille / hauteur mesurée) | Oui (::details-content) |
| Un seul ouvert | État manuel (openId) | Attribut name |
| Clavier + a11y | Vous le câblez (ou une bibliothèque) | Natif |
| Fonctionne avant l'hydratation / sans JS | Non | Oui |
| Bascule du chevron | Classe basculée par JS | CSS sur [open] |
JavaScript garde l'avantage lorsque le contenu du panneau est réellement dynamique — chargé à la demande, piloté par l'état de l'application, ou lorsque l'ouverture doit déclencher des effets de bord que vous contrôlez en JS. Mais pour le cas de très loin le plus courant — une FAQ présentationnelle ou une liste de divulgation — l'élément natif égale la version JS fonctionnalité pour fonctionnalité, sans rien du bundle.
À retenir
- L'accordéon JavaScript que tout le monde écrit existe surtout parce que
<details>natif ne pouvait pas s'animer — une limitation désormais levée. ::details-content+interpolate-size: allow-keywordsaniment la hauteur entre0etauto.- L'astuce qui rend la fermeture animée (et pas seulement l'ouverture) est
transition-behavior: allow-discretesurcontent-visibility. <details name="…">vous donne un groupe exclusif « un seul ouvert à la fois » sans le moindre JavaScript — utilisez un nom unique par accordéon.- La bascule du chevron est une
transformCSS liée à l'attribut[open]. - La dégradation est gracieuse sans
@supports: les vieux navigateurs ouvrent/ferment instantanément, et l'animation est une pure amélioration.
Aucune de ces pièces n'est exotique — <details> a des décennies, et les fonctionnalités d'animation sont du CSS banal et bien spécifié. Le changement, c'est de réaliser qu'il n'est plus nécessaire de recourir à un état React pour obtenir un accordéon soigné. Supprimez le composant client, livrez du HTML statique, et laissez le navigateur faire ce qu'il sait discrètement faire depuis toujours.