Skip to main content
Retour au blog
CSSHTMLAccordionFrontendPerformance

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.

Publié 28 juillet 20269 min de lecture

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 :

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; }

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-content vous donne prise sur la région de contenu masquée, que vous ne pouviez auparavant pas sélectionner du tout.
  • interpolate-size: allow-keywords autorise l'animation de block-size entre 0 et la hauteur intrinsèque auto — ce que le CSS a historiquement refusé de faire.
  • transition-behavior: allow-discrete (en listant content-visibility dans 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

EnjeuAccordéon JS (état + hook)<code>&lt;details&gt;</code> natif + CSS
Ouverture / fermetureÉtat React + onClickIntégré au navigateur
JavaScript livréOui (composant client, hydrate)Aucun
Animation fluideOui (grille / hauteur mesurée)Oui (::details-content)
Un seul ouvertÉtat manuel (openId)Attribut name
Clavier + a11yVous le câblez (ou une bibliothèque)Natif
Fonctionne avant l'hydratation / sans JSNonOui
Bascule du chevronClasse basculée par JSCSS 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-keywords animent la hauteur entre 0 et auto.
  • L'astuce qui rend la fermeture animée (et pas seulement l'ouverture) est transition-behavior: allow-discrete sur content-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 transform CSS 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.