Le menu déroulant qui refusait qu'on clique dessus
Un menu s'affichait parfaitement, se plaçait exactement où il fallait, et avalait chaque clic. Le coupable était une animation d'entrée qui ne se terminait jamais vraiment. Petit tour des contextes d'empilement CSS, des modes de remplissage d'animation, et de la façon de traquer un bug qu'on ne voit pas.
Nous avons livré un menu déroulant sur lequel on ne pouvait pas cliquer. Il s'ouvrait bien. Il s'affichait au bon endroit, parfaitement visible, avec le bon z-index et sans erreur en console. Les clics le traversaient de part en part pour atteindre le panneau en dessous, comme si le menu était un fantôme. Si vous vous êtes déjà battu contre un bug de ce genre, vous connaissez déjà la partie déroutante : tout a l'air juste, et rien ne fonctionne.
Savoir qui est vraiment au-dessus
La première réponse honnête est venue du navigateur, pas de la lecture du code. document.elementFromPoint vous dit quel élément reçoit réellement un clic à une coordonnée donnée. Nous l'avons pointé au centre d'une entrée de menu, et il a renvoyé une carte située plus loin dans la page. Le menu était visuellement au-dessus et physiquement en dessous.
// Who really gets the click at this point?const item = document.querySelector('[role="menuitem"]');const r = item.getBoundingClientRect();const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);console.log(top === item || item.contains(top)); // false -> something covers it // Walk the ancestors and list everything that creates a stacking contextfor (let el = item; el; el = el.parentElement) { const cs = getComputedStyle(el); const reasons = []; if (cs.transform !== 'none') reasons.push('transform'); if (cs.filter !== 'none') reasons.push('filter'); if (cs.backdropFilter !== 'none') reasons.push('backdrop-filter'); if (cs.opacity !== '1') reasons.push('opacity'); if (cs.position !== 'static' && cs.zIndex !== 'auto') reasons.push('z-index'); if (reasons.length) console.log(el.className, reasons);}Le parcours l'a trouvé. L'en-tête qui contenait le menu déclarait un transform, alors qu'aucune feuille de style ne lui en donnait. Le seul transform dans les parages était une animation d'entrée, un petit fondu montant qui s'était terminé une demi-seconde après le chargement de la page.
Les modes de remplissage survivent à leur animation
animation-fill-mode: both veut dire que l'élément tient la première image clé avant le début de l'animation et garde la dernière image clé après sa fin. Il la garde, pour toujours. Si une image clé anime transform, l'animation terminée applique encore un transform, et un élément transformé devient un contexte d'empilement. Le z-index de chaque descendant est désormais enfermé à l'intérieur, en concurrence avec ses seuls frères locaux, jamais avec le reste de la page.
Notre page avait, après l'en-tête, des panneaux qui utilisaient backdrop-filter, ce qui crée son propre contexte d'empilement. Deux boîtes scellées, et l'ordre de peinture entre elles décidé par l'ordre dans l'arbre, pas par le z-index que nous continuions à monter dans la confusion. Le menu aurait pu avoir un z-index d'un million. À l'intérieur de sa boîte, personne au-dehors ne pouvait l'entendre.
Et voici le piège dans le piège. Nous avons changé la dernière image clé en transform: none, en nous disant que la valeur retenue serait alors inoffensive. Le style calculé rapportait toujours une matrice. Les navigateurs résolvent le transform d'une animation appliquée comme une valeur matricielle, et une matrice identité compte encore comme un transform du point de vue de l'empilement. Le contexte d'empilement a survécu à notre correctif.
/* BAD: the finished animation keeps applying its last keyframe forever. The header remains a stacking context for the life of the page. */.enter { animation: rise 0.5s ease-out both;} /* GOOD: fill backwards only. The element returns to its natural state after the animation, which is identical to the final keyframe anyway. */.enter { animation: rise 0.5s ease-out backwards;} @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; }}La règle que nous avons écrite
Les animations d'entrée sur des conteneurs remplissent en arrière, jamais des deux côtés. Le remplissage en arrière couvre le seul trou qui compte, les images d'avant le démarrage d'une animation retardée. Une fois l'animation finie, l'élément s'en détache complètement et cesse d'être un contexte d'empilement. Le résultat visuel est identique, parce qu'une bonne animation d'entrée se termine sur l'état naturel de l'élément.

- Faites plus confiance à elementFromPoint qu'à vos yeux. L'ordre visuel et l'ordre de détection des clics peuvent diverger, et un seul des deux reçoit le clic.
- Quand le z-index cesse d'avoir du sens, arrêtez de le monter et partez à la chasse aux contextes d'empilement. Le parcours des ancêtres ci-dessus prend une minute.
- Auditez ce que vos animations terminées laissent derrière elles. getComputedStyle sur une page au repos vous dit ce qui est encore appliqué.
- Un transform retenu reste un transform, même quand l'image clé dit none.
L'animation durait une demi-seconde. Son effet de bord n'avait aucune heure de fin.