Une balise details isolée, c'est le pattern disclosure (show/hide) : rien à ajouter, le natif suffit. Dès qu'on groupe plusieurs details avec ouverture exclusive, on bascule sur un pattern accordéon, avec des attentes d'interaction propres, navigation clavier entre panneaux du même ensemble, notamment. Certes, c'est du niveau WCAG AAA, mais tellement plus confortable pour les utilisateurs.
L'attribut name sur details donne nativement l'exclusivité de ce groupe, comportement type radio. Support large, aucune raison de dupliquer cette logique en Javascript : exclusivité de groupe est entièrement déléguée au natif.
Il ne reste qu'une limite réelle : aucune navigation clavier flèches haut/bas entre les summary d'un même groupe. Le script se concentre uniquement là-dessus, en utilisant name comme clé de rattachement pour l'indexation, sans identifiant généré ni classe de groupement.
Exemples
Le code de base, simple.
<details>
<summary>Ma balise details de base</summary>
<p>
Lorem ipsum etc.
</p>
</details>
Ce qui donne quelque chose comme ça.
Ma balise details de base
Texte fictif : Lorem tincidunt diam euismod faucibus gravida nisl ante mauris molestie nulla praesent ligula luctus cras orci ligula lectus pellentesque id ipsum aliquam posuere quam odio non rhoncus ut nunc vitae pretium elit aliquam egestas orci risus erat in leo pellentesque duis rhoncus ac non elit nec sed
Le groupe version accordions
Ce n'est pas bien compliqué : même code mais avec un attribut name commun aux balises details.
Premier élément avec open.
<div class="group-details-line-separator">
<details name="mon-groupe" open>
<summary style="display:block;">Ma balise details n°1 sur 3</summary>
<p>
Lorem aenean arcu leo non pellentesque vivamus in proin tortor ultrices euismod felis tellus sapien amet non blandit metus elit duis ut convallis in faucibus ac aliquam tincidunt in varius enim ullamcorper volutpat suspendisse
</p>
</details>
<details name="mon-groupe">
<summary style="display:block;">Ma balise details n°2 sur 3</summary>
<p>
Lorem diam lectus praesent cras convallis dolor porttitor ornare fusce sodales non et eu ligula dolor nunc mauris tincidunt non faucibus semper ligula ullamcorper nisl suscipit vitae placerat mi nec leo ultrices et maecenas vel et sapien adipiscing lobortis sit sit massa aliquam mauris orci in metus enim in nulla vestibulum duis mi
</p>
</details>
<details name="mon-groupe">
<summary style="display:block;">Ma balise details n°3 sur 3</summary>
<p>
Lorem ornare duis eros elit amet sit suspendisse amet suspendisse at imperdiet dolor euismod proin sit sed velit diam lacus nulla hendrerit aliquam sed felis vitae molestie varius lectus nibh mauris integer pellentesque rhoncus in dignissim lorem ipsum diam ullamcorper erat augue faucibus a egestas quis nunc nulla erat praesent
</p>
</details>
</div>
Pour afficher le visuel de toggle à droite, un simple style="display:block;" sur summary.
Les éléments sont regroupés dans un bloc avec classe CSS group-details-line-separator qui permet de gérer une ligne entre chaque élément, sans ajout dans le DOM.
Ma balise details n°1 sur 3
Texte fictif : Lorem convallis mollis vitae metus leo curabitur congue suscipit mauris nulla in congue eu vulputate in morbi mauris nulla in ullamcorper integer massa fusce aenean sapien dui felis ante proin non leo aliquam sed sollicitudin sed duis lorem varius sed egestas blandit massa in dignissim enim lectus tincidunt sed
Ma balise details n°2 sur 3
Texte fictif : Lorem tincidunt maecenas nulla orci risus quis aenean vitae nibh metus proin amet faucibus id fermentum faucibus eu hendrerit justo adipiscing vitae ultricies nonummy amet tortor augue dui aliquam ut ut varius id augue quam egestas nisl rutrum duis varius et fermentum fusce nulla felis suscipit elit arcu consequat semper pharetra
Ma balise details n°3 sur 3
Texte fictif : Lorem tellus nec tincidunt cubilia nonummy nisl mauris imperdiet pulvinar massa primis congue in amet turpis erat erat elementum sapien poda lorem magna integer volutpat elit augue vehicula fermentum aliquam fermentum eu consectetur mauris a semper mi purus maecenas consectetuer odio in non risus sed nunc vel enim posuere sit
Navigation clavier
Flèches haut/bas déplacent le focus entre les summary du même [name] via un roving géré par dataset (data-index, data-prev, data-next), sans ouvrir automatiquement le panneau ciblé. L'ouverture reste sur Entrée/Espace/clic natif.
Choix assumé : le coût d'activer un panneau au simple passage du focus (comme un radiogroup) est plus élevé que le bénéfice, pour un composant dont l'ouverture peut charger du contenu ou déplacer visuellement la page.
Le script
Ce n'est pas bien lourd, ni compliqué.
Taille brute : 9,55 kilo-octets (~3,51 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2026/07/07/accordions-html-natifs-et-accessibles-avec-details-et-summary.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
/**
* Gestion optimisée des éléments <details> avec navigation clavier.
* L'exclusivité de groupe est déléguée au natif via [name] (pas de JS pour l'ouverture exclusive).
* Utilise le pattern "batch reads / batch writes" (séparation READ/WRITE) pour éviter le layout
* thrashing lors de l'initialisation en masse : toute lecture layout-sensible (getBoundingClientRect,
* innerWidth) est effectuée avant la moindre écriture DOM sur l'ensemble des éléments traités.
* Documentation générée par Claude AI
* @module DetailsManager
*/
(() => {
document.addEventListener('DOMContentLoaded', () => {
// Raccourcis de capture d'événements clavier
const KEYS = {ENTER: 'Enter', SPACE: ' ', UP: 'ArrowUp', DOWN: 'ArrowDown'};
// clés de traduction par défaut si inexistantes
const i18n = window.i18n || {};
i18n.defaultSummary ??= 'Détails';
i18n.emptyDetails ??= 'Aucun contenu';
i18n.detailsDisabled ??= 'Panneau inactif ouvert';
/**
* Lecture groupée (phase READ) des propriétés d'un <details> nécessaires à son initialisation.
* Isole tous les accès sensibles au layout avant toute écriture DOM, condition requise pour
* que initDetails puisse traiter un lot d'éléments sans reflow intermédiaire.
* @param {HTMLDetailsElement} details - Élément <details> à analyser
* @returns {{
* details: HTMLDetailsElement,
* summary: HTMLElement|null,
* name: string|null,
* disableToggle: number,
* width: number,
* hasContent: boolean,
* isDisabled: boolean
* }} Snapshot consommé par applyDetailsSetup et attachDetailsEvents
*
* - name : attribut natif [name] du groupe d'exclusivité, réutilisé pour l'indexation clavier
* - disableToggle : seuil de largeur en px sous/au-delà duquel le toggle est neutralisé (NaN si absent)
* - width : largeur de référence, celle du composant si [data-self-size], sinon celle du viewport
* - hasContent : présence d'un contenu réel après la <summary> (élément ou texte non vide),
* les nœuds texte uniquement blancs sont ignorés pour ne pas considérer un panneau vide comme rempli
*/
function collectDetailsData(details) {
const summary = details.querySelector('summary');
const name = details.getAttribute('name');
const disableToggle = parseInt(details.getAttribute('data-disable-toggle'), 10);
const width = details.hasAttribute('data-self-size') ? details.getBoundingClientRect().width : window.innerWidth;
let hasContent = false;
if (summary) {
let node = summary.nextSibling;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '')) {
hasContent = true;
break;
}
node = node.nextSibling;
}
}
return {
details,
summary,
name,
disableToggle,
width,
hasContent,
isDisabled: details.classList.contains('details-disabled')
};
}
/**
* Applique les modifications DOM (phase WRITE) à partir du snapshot collecté.
* Regroupe toutes les écritures relatives à un même <details> pour limiter les reflows successifs.
* @param {ReturnType<typeof collectDetailsData>} data - Snapshot ; `summary` est muté en place si créée ici
* @returns {void}
*
* - Injecte une <summary> par défaut si absente (requis pour un <details> valide et accessible)
* - Renseigne aria-label à partir du texte de la summary, utile si un rendu visuel (icône, troncature CSS)
* rend le libellé ambigu pour les technologies d'assistance
* - Neutralise le toggle si la largeur dépasse data-disable-toggle, force l'ouverture et documente
* la raison via title (cas d'un accordéon converti en grille statique au-delà d'un breakpoint)
* - Neutralise et force l'ouverture si le panneau est vide ou marqué .details-disabled, avec un message
* explicite plutôt que de laisser un <details> vide manipulable au clavier sans retour utilisateur
*/
function applyDetailsSetup(data) {
const {details, summary, disableToggle, width, hasContent, isDisabled} = data;
if (!summary) {
const newSummary = document.createElement('summary');
newSummary.textContent = i18n.defaultSummary;
newSummary.classList.add('display-block');
details.insertBefore(newSummary, details.firstChild);
data.summary = newSummary;
}
data.summary.setAttribute('aria-label', data.summary.textContent.trim());
if (!isNaN(disableToggle) && width >= disableToggle) {
details.dataset.prevent = 'true';
data.summary.title = i18n.detailsDisabled;
details.open = true;
}
if (!hasContent || isDisabled) {
details.dataset.prevent = 'true';
data.summary.title = i18n.detailsDisabled;
const msg = document.createElement('p');
msg.className = 'empty-details';
msg.textContent = i18n.emptyDetails;
details.appendChild(msg);
details.open = true;
}
}
/**
* Construit la table de navigation circulaire (précédent/suivant) pour un groupe [name].
* L'exclusivité d'ouverture reste native (attribut HTML [name]) ; cette fonction ne gère
* que le déplacement du focus au clavier (Flèches Haut/Bas) entre les summaries du groupe.
* @param {string} name - Valeur de l'attribut [name] partagé par le groupe de <details>
* @returns {void}
*
* Stocke sur chaque <summary> du groupe :
* - data-index : position dans l'ordre du DOM
* - data-prev / data-next : index cible en navigation circulaire (le dernier boucle vers
* le premier et inversement), consommés par attachDetailsEvents
*/
function indexGroupSummaries(name) {
const summaries = document.querySelectorAll(`details[name="${CSS.escape(name)}"] > summary`);
summaries.forEach((s, i) => {
s.dataset.index = String(i);
s.dataset.prev = String(i === 0 ? summaries.length - 1 : i - 1);
s.dataset.next = String(i === summaries.length - 1 ? 0 : i + 1);
});
}
/**
* Attache les écouteurs clavier et souris à un <details> déjà configuré par applyDetailsSetup.
* @param {ReturnType<typeof collectDetailsData>} data - Snapshot après passage en phase WRITE
* @returns {void}
*
* - click sur summary : intercepté si dataset.prevent est défini (toggle neutralisé par largeur
* ou contenu vide), empêche l'ouverture/fermeture native dans ce cas
* - keydown : preventDefault sur Espace/Entrée/Flèches pour éviter le double déclenchement natif
* du <details> et le scroll de page induit par défaut sur ces touches
* - keyup Espace/Entrée : déclenche summary.click() (sauf si .details-disabled), reproduisant
* l'activation native tout en respectant l'interception faite au keydown
* - keyup Flèches Haut/Bas : si le <details> appartient à un groupe [name], déplace le focus
* vers la summary précédente/suivante via data-prev/data-next (roving focus type accordéon ARIA)
*/
function attachDetailsEvents(data) {
const {details, summary, name} = data;
summary.addEventListener('click', e => {
if (details.dataset.prevent) {
e.preventDefault();
}
});
summary.addEventListener('keydown', e => {
if ([KEYS.SPACE, KEYS.ENTER, KEYS.UP, KEYS.DOWN].includes(e.key)) {
e.preventDefault();
}
});
summary.addEventListener('keyup', e => {
const {key} = e;
if ([KEYS.SPACE, KEYS.ENTER].includes(key) && !details.classList.contains('details-disabled')) {
summary.click();
}
if (name) {
const targetIndex = key === KEYS.UP ? summary.dataset.prev : key === KEYS.DOWN ? summary.dataset.next : null;
if (targetIndex !== null) {
const target = document.querySelector(`details[name="${CSS.escape(name)}"] > summary[data-index="${targetIndex}"]`);
if (target) target.focus();
}
}
});
}
/**
* Point d'entrée : initialise tous les <details> d'un conteneur selon le pattern batch read/write.
* @param {HTMLElement} [container=document.body] - Racine de recherche des <details>
* @returns {void}
*
* Séquencement strict pour éviter le layout thrashing sur un lot d'éléments :
* 1. READ - collecte des données de tous les <details> du conteneur, aucune écriture DOM
* 2. WRITE - application des modifications DOM (summary par défaut, aria-label, états neutralisés)
* 3. INDEX - construction des tables de navigation clavier par groupe [name]
* 4. BIND - attache des écouteurs une fois le DOM final stabilisé
*/
function initDetails(container = document.body) {
const detailsList = container.querySelectorAll('details');
const names = new Set();
const allData = Array.from(detailsList).map(details => {
const data = collectDetailsData(details);
if (data.name) names.add(data.name);
return data;
});
allData.forEach(data => applyDetailsSetup(data));
names.forEach(name => indexGroupSummaries(name));
allData.forEach(data => attachDetailsEvents(data));
}
initDetails();
});
})();
Télécharger kortic-disclosure-details.js
Tout est expliqué dans la doc, mais un peu d'explication.
Lecture/écriture séparées
Le script distingue une phase de lecture (collectDetailsData) et une phase d'écriture (applyDetailsSetup), pour éviter les reflows forcés qu'un getBoundingClientRect() intercalé entre deux mutations DOM provoquerait sur une boucle querySelectorAll('details').
Toutes les lectures sont collectées avant la moindre écriture. indexGroupSummaries (indexation clavier) vient dans un troisième temps, une fois tous les [name] connus.
Cas particuliers en bonus
- contenu vide détecté par parcours des nœuds frères du
summary: désactivation + message plutôt qu'un contrôle actionnable sans effet perceptible. data-disable-togglecomparé à une largeur de l'élément viadata-self-sizeou du viewport : force l'ouverture et neutralise le toggle au-delà d'un seuil. Cas d'usage : une liste dépliable en mobile qui devient une liste statique en desktop.
Regarder les navigations dans le pied de page, en version mobile et desktop…
Aucun lien dans summary
summary porte une sémantique implicite de contrôle interactif. Un lien imbriqué dedans relève du même défaut qu'un button > a.
Critères concernés
- WCAG 4.1.2 (Nom, rôle, valeur) : un contrôle interactif imbriqué dans un autre casse l'exposition rôle/état pour les technologies d'assistance, le comportement réel dépend alors de l'implémentation navigateur/AT plutôt que de la spécification.
- WCAG 2.1.1 (Clavier) : le lien imbriqué doit rester atteignable et activable indépendamment du toggle du panneau, non garanti dans ce cas de figure.
- EN 301 549, clauses 9.4.1.2 et 9.2.1.1 (reprise directe des deux critères ci-dessus).
Les styles
Une balise details sans personnalisation, c'est laid. Si si.
Cadeau, une base de travail avec pictogrammes intégrés, en plusieurs versions.
Taille brute : 5,05 kilo-octets (~1,61 kilo-octets gzip)
details {
--svg-chevron: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 16"><path d="m.31.71C.8.09,1.6.09,2.1.59l11.99,12.02L25.87.38c.52-.54,1.32-.5,1.8.09.47.59.44,1.5-.08,2.04l-12.54,13.11c-.48.5-1.21.5-1.71.02L.56,2.75C-.11,2.23-.16,1.31.31.71Z"/></svg>');
--svg-double-chevron: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path opacity=".4" d="M24.23,11.26l-.88.94c-.33.37-.9.39-1.27.05,0,0-.02-.02-.02-.02,0,0-.02-.02-.02-.02l-6.02-6.4-6.03,6.41c-.33.37-.9.39-1.27.05,0,0-.02-.02-.02-.02,0,0-.02-.02-.02-.02l-.89-.94c-.36-.4-.36-1.01,0-1.41h0S15.34,1.79,15.34,1.79c.33-.37.9-.39,1.27-.05,0,0,.02.02.02.02,0,0,.02.02.02.02l1.56,1.66h0s6.01,6.4,6.01,6.4c.36.4.36,1.01,0,1.41Z"/><path d="M24.23,22.13h0s-7.57,8.08-7.57,8.08c-.33.37-.9.39-1.27.05,0,0-.02-.02-.02-.02,0,0-.02-.02-.02-.02l-1.56-1.66h0s-6.01-6.4-6.01-6.4c-.36-.4-.36-1.01,0-1.41l.88-.94c.33-.37.9-.39,1.27-.05,0,0,.02.02.02.02,0,0,.02.02.02.02l6.02,6.4,6.03-6.41c.33-.37.9-.39,1.27-.05,0,0,.02.02.02.02,0,0,.02.02.02.02l.89.94c.36.4.36,1.01,0,1.41Z"/></svg>');
--svg-marker-less-thin: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31,16.63H1v-2h30v2Z"/></svg>');
--svg-marker-more-thin: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31,16.63H1v-2h30v2ZM16.89,1v30h-2V1h2Z"/></svg>');
--svg-marker-cross: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26.16,27.05L4.95,5.84l1.41-1.41,21.21,21.21-1.41,1.41ZM27.23,6.02L6.02,27.23l-1.41-1.41L25.82,4.61l1.41,1.41Z"/></svg>');
& + details {
margin-top: var(--small-gutter, 1.25rem);
position: relative;
}
& > summary {
position: relative;
list-style: none;
display: inline-block;
max-width: 100%;
&::-webkit-details-marker,
&::marker {
display: none;
}
}
&:not([data-prevent]) > summary {
padding-right: 2em;
}
&:not([data-prevent])[open] > summary {
color: var(--link-color, LinkText);
}
&[data-prevent]:not([data-disable-toggle]) > summary {
font-style: italic;
}
/* marqueur ::after — absent si .no-marker */
&:not(.details-disabled, .no-marker) > summary::after {
content: "\A0" / "";
height: 1em;
width: 1em;
pointer-events: none;
position: absolute;
right: 0.5em;
top: 50%;
transform: translateY(-50%);
-webkit-mask-position: center center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-position: center center;
mask-repeat: no-repeat;
mask-size: contain;
background-color: currentColor;
-webkit-mask-image: var(--svg-chevron);
mask-image: var(--svg-chevron);
}
&[data-prevent] > summary::after {
-webkit-mask-image: none;
mask-image: none;
background-color: transparent;
}
/* variantes de marqueur */
&.plus-type {
& > summary::after {
top: 0.8125em;
-webkit-mask-image: var(--svg-marker-more-thin);
mask-image: var(--svg-marker-more-thin);
}
&[open] > summary::after {
-webkit-mask-image: var(--svg-marker-less-thin);
mask-image: var(--svg-marker-less-thin);
}
}
&.cross-closer[open] > summary::after {
-webkit-mask-image: var(--svg-marker-cross);
mask-image: var(--svg-marker-cross);
}
&.double-chevron {
& > summary::after {
-webkit-mask-image: var(--svg-double-chevron);
mask-image: var(--svg-double-chevron);
width: 1.5em;
height: 1.5em;
}
&[open] > summary::after {
transform: scaleY(-1) translateY(50%);
}
}
/* chevron rotation standard — exclut plus-type, double-chevron, cross-closer */
&:not(.plus-type, .double-chevron, .cross-closer)[open] > summary::after {
transform: scaleY(-1) translateY(50%);
}
/*Animation native moteur Chromium */
&::details-content {
block-size: 0;
display: block;
overflow: hidden;
}
/*
Comme on est sympas, on ne va animer les balises seulement si le navigateur l'autorise…
*/
@media (prefers-reduced-motion: no-preference) {
&::details-content {
transition-property: block-size, content-visibility;
transition-behavior: allow-discrete;
transition-duration: .5s;
}
&:not(.cross-closer, .double-chevron) > summary::after {
transition: transform .1s;
}
}
&[open]::details-content {
block-size: auto;
block-size: calc-size(max-content, size);
}
@media (any-hover: hover) {
&:not(.details-disabled) > summary {
cursor: pointer;
}
&[data-prevent] > summary {
cursor: default;
}
&:not([data-prevent], .details-disabled) > summary:hover {
color: var(--link-color, LinkText);
}
}
}
/* séparateur groupe */
.group-details-line-separator > details + details {
margin-top: var(--half-gutter, 1.25rem);
padding-top: var(--half-gutter, 1.25rem);
box-shadow: 0 1px 0 0 rgba(122, 122, 122, 0.2) inset;
}
Télécharger kortic-disclosure-details.css
Marqueur en mask-image
Le chevron est posé en mask-image plutôt qu'en background-image, piloté par currentColor. Un seul SVG encodé en custom property sert toutes les déclinaisons de couleur (thème clair/sombre, état hover, état ouvert) sans dupliquer d'assets.
&:not(.details-disabled, .no-marker) > summary::after {
background-color: currentColor;
-webkit-mask-image: var(--svg-chevron);
mask-image: var(--svg-chevron);
}
Quand le toggle est désactivé ([data-prevent]), le masque est explicitement retiré : pas de chevron sur un contrôle qui n'agit plus comme un toggle.
Variantes de marqueur
plus-type, double-chevron, cross-closer permutent la custom property de masque selon l'état [open], sans réécrire les propriétés de positionnement communes.
Gestion de reduced motion
La transition de rotation du chevron n'est posée que sous @media (prefers-reduced-motion: no-preference), jamais par défaut.
@media (prefers-reduced-motion: no-preference) {
details:not(.cross-closer, .double-chevron) > summary::after {
transition: transform .1s;
}
}
That's all, Folks !