Afficher et masquer des éléments sur le web
Afficher et masquer des éléments, c’est un peu la base quand on crée une application web. Cependant, au travers de nombreuses discussions, je me suis rendu compte que beaucoup de développeurs web avaient du mal à comprendre comment les navigateurs (ou plus généralement les frameworks qu’ils utilisaient) géraient la question.
On va donc prendre le temps ici de détailler les fondements nécessaires pour faire apparaître et disparaître des éléments correctement.
De la notion d’invisibilité
Avant de rentrer dans le vif du sujet, on va commencer par définir une notion extrêmement importante : qu’est-ce que ça veut dire exactement pour un élément d’être invisible ?
Alors oui, je vous vois venir : « Ben, ça veut dire qu’on ne voit pas l’élément ». Certes, certes, certes… Alors déjà, c’est un peu réducteur car pour un navigateur un élément peut être masqué visuellement (il n’est pas affiché sur l’écran) mais aussi masqué auditivement (il ne peut pas être lu et restitué par une assistance vocale). Ainsi, quand on parle d’invisibilité, il faut comprendre invisibilité visuelle ou auditive, l’une pouvant être possible sans l’autre. Mais bon pour éviter toute confusion, dans le reste de l’article je parlerai de perceptibilité. 😉
Donc, si vous le voulez bien, on va creuser un peu en se demandant pourquoi un élément n’est pas perceptible.
L’élément n’est pas présent dans l’arbre DOM du document
Ça semble évident mais il faut comprendre ce que ça veut dire exactement.
En effet, quand on manipule le DOM, il n’est pas rare de créer ou déplacer des nœuds, voire des fragments entiers de document. Or, la façon dont les navigateurs fonctionnent fait que tout nœud (et ses enfants) qui n’est pas rattaché directement ou par l’intermédiaire de ses parents au nœud racine du document sera totalement invisible et inaudible car il n’est tout simplement pas inclus dans la routine d’affichage du navigateur (on va revenir là-dessus).
C’est à la fois génial puisque ça permet de préparer tranquillement un fragment d’arbre DOM sans se soucier de ce que percevront les utilisateurs et une malédiction si vous détachez un nœud par accident (attacher, détacher, manipuler et afficher des nœuds DOM sont en général des opérations vite coûteuses qu’on veut réduire au strict minimum).
L’élément a des attributs qui le rendent imperceptible
HTML n’est pas censé gérer de la présentation (c’est le rôle de CSS, on en reparle dans un instant) mais il existe tout de même deux attributs qui ont un impact sur la perceptibilité des éléments : hidden
et aria-hidden
.
L’attribut global hidden
est un indicateur pour le navigateur indiquant que l’élément (et ses enfants) n’est pas pertinent pour l’utilisateur dans le contexte courant du document (une indication sémantique tout à fait valable). Par conséquent, un navigateur ne va pas faire le rendu de l’élément, ce qui le rend invisible, et ne va pas l’inclure dans l’arbre d’accessibilité, ce qui le rend inaudible. Cependant, si CSS définit explicitement une valeur pour la propriété display
de cet élément, celui-ci sera affiché visuellement, malgré la présence de l’attribut hidden
.
De son côté, l’attribut aria-hidden
va uniquement exclure l’élément de l’arbre d’accessibilité, il sera donc inaudible. Cependant, si rien n’est fait avec CSS, l’élément restera visible.
Ainsi, parce que c’est CSS qui décide véritablement ce qui est perceptible, on va souvent accompagner ces deux attributs d’une petite feuille de style qui met les choses bien au clair :
[hidden],
[aria-hidden=”true”] {
display: none !important; /* 🔨💥😵💫 */
}
L’élément a un style qui le rend imperceptible
Bonne transition pour aborder la question de CSS. CSS est ce qui permet de contrôler l’apparence des éléments, ce qui aura un impact direct sur leur perceptibilité. En théorie, CSS n’impacte que la perceptibilité visuelle, en pratique… c’est compliqué !
La façon la plus radicale de rendre un élément imperceptible, aussi bien d’un point de vue visuel que d’un point de vue auditif, c’est la propriété display
avec la valeur none
. Pour un navigateur, ça revient à considérer que ce nœud doit être traité comme s’il était détaché de l’arbre DOM (On va voir ci-après que ça a des impacts non négligeables).
D’autres propriétés CSS permettent de rendre un élément simplement invisible: opacity
, visibility
. Certaines propriétés peuvent être détournées pour masquer un élément: transform:scale(0)
, clip-path:inset(100% 100% 0 0)
. Mais on peut aussi être très créatif en utilisant overflow:hidden
et en repoussant le contenu des éléments hors de la zone visible (text-indent:10000rem
, padding-left:100%
, …) ou en réduisant leur taille à 0 (width:0
, height:0
)
Normalement, l’usage de ces propriétés ne devrait pas rendre l’élément inaudible… la réalité c’est que chaque navigateur fait un peu ce qu’il veut. Dans certains cas on a un consensus, par exemple visibility:hidden
rendra un élément inaudible dans tous les navigateurs (mais pas opacity:0
). Dans d’autre cas on a des particularités comme par exemple: donner un width:0
ou height:0
à un élément le rendra spécifiquement inaudible par VoiceOver avec Safari.
L’élément est caché par un autre élément
C’est un cas un peu particulier qu’on peut rencontrer quand on utilise un peu maladroitement les propriétés CSS de positionnement. Que ce soit en utilisant les propriétés position
et z-index
(l’empilement d’éléments et la compréhension des stacking context est un sujet à part entière) ou en utilisant un display:grid
et en empilant plusieurs éléments dans une même cellule de grille.
L’élément n’est pas présent dans le viewport
Plus généralement, si un élément est en dehors de la zone visible d’un overflow, que ce soit la fenêtre du navigateur (le viewport) ou n’importe quel élément du DOM avec un overflow:hidden
, alors cet élément ne sera pas visible mais restera audible. C’est un cas un peu particulier qu’on ne développera pas plus ici, mais sachez que si vous voulez savoir si un élément est dans une zone visible, l’API Intersection Observer est là pour vous aider.
Le dur et le doux
La question de la perceptibilité étant réglée on va donc pouvoir maintenant définir plusieurs états qui vont définir formellement la perceptibilité de nos éléments.
Commençons par la question simple de la restitution vocale des éléments. On est dans un cas assez binaire : soit les lecteurs d’écran peuvent restituer un élément soit ils ne le peuvent pas.
Pour ce qui est de la restitution visuelle, c’est un peu plus délicat car les navigateurs ont un cycle de gestion de l’affichage des éléments un peu compliqué qu’on appel layout et qui suit un certain nombre d’étapes plus ou moins obligatoires et que je vais résumer très sommairement au travers du schéma suivant :

Tout changement dans le DOM (Insérer ou retirer un nœud, modifier la valeur d’un attribut ou d’une propriété CSS, etc.) va, dans la plupart des cas, exiger que le navigateur recalcule tout ou partie du layout de la page, à savoir la taille et la position des éléments. Une fois que c’est fait, le navigateur va calculer les pixels correspondant à chaque élément, c’est le paint. Enfin, le navigateur va prendre les pixels de chaque élément présent dans le viewport pour les assembler et les afficher sur l’écran, c’est le compositing.
Les étapes combinées de layout et de paint sont parfois appelées reflow et peuvent être particulièrement longues et complexes. Aussi va-t-on toujours s’efforcer de les réduire au maximum et de les répéter le moins souvent possible. Ceci dit, les navigateurs eux-mêmes essayent d’optimiser le reflow au maximum, voire de l’éviter si possible. De ce point de vue, toutes les propriétés CSS n’ont pas le même impact. Par exemple, modifier la propriété display
exigera de faire un recalcul complet de la page là ou une modification de la propriété opacity
va permettre d’ignorer le reflow et de directement passer au compositing (en changeant juste l’opacité des textures concernées… oui, les navigateurs font du rendu 3D)
Les transitions CSS s’inscrivent dans ce cycle de vie de l’affichage en faisant des calculs différentiels entre un layout/paint/compositing de départ et un d’arrivée. Ainsi, pour qu’une transition se joue, l’élément ciblé doit déjà avoir traversé tout ce cycle de rendu au moins une fois. En conséquence, lorsqu’un élément est inséré dans le DOM ou bien si on retire la valeur none
à la propriété CSS display
, le navigateur va calculer et afficher l’état visuel final de l’élément mais il ne jouera pas les transitions qui y sont attachées car il n’existait pas de rendu de référence nécessaire pour créer les interpolations de l’animation (le problème existe aussi dans l’autre sens mais cette fois parce qu’il n’y a pas d’état d’arrivée, il n’y a juste plus rien).
Cette spécificité de l’affichage des éléments et des transitions CSS nous contraint donc à définir deux états d’invisibilité : D’une part un état dur dont l’entrée ou la sortie seront immédiats et d’autre part un état doux dont on pourra sortir ou entrer avec une jolie transition.
En général, l’état dur se caractérise par l’application de l’attribut hidden
et de la propriété display:none
sur l’élément, l’état doux se caractérisant par n’importe quelle propriété CSS qui masque l’élément visuellement sans empêcher sa restitution vocale (on préférera généralement gérer la question de la restitution vocale avec l’attribut aria-hidden
pour bien différencier les responsabilités).
Les personnes habituées de l’accessibilité on d’ailleurs déjà dû voir ce genre de style pour définir un état d’invisibilité douce :
.hide {
position: absolute;
overflow: hidden;
height: 1px;
width: 1px;
clip: rect(0,0,0,0);
}
Jouer à cache-cache
Bien, avec tout ça en main, on va maintenant pouvoir voir comment afficher et masquer des éléments visuellement en gardant la maîtrise de notre affichage (et de nos performances, et de l’accessibilité, etc).
Un coup tu me vois pas 🙈
On va commencer par la version facile, masquer un élément perceptible. Ainsi, sur l’élément que l’on veut faire disparaître nous allons :
- Appliquer une classe CSS (ou modifier une propriété de l’attribut
style
dans le DOM) qui représente un état invisible doux. - Attendre la fin d’une éventuelle transition CSS
- Ajouter l’attribut
hidden
et le styledisplay:none
a notre élément (ou éventuellement le retirer du DOM)
Évidemment, selon ce que vous voulez obtenir comme effet, les étapes deux et trois sont optionnelles, mais on va quand même les traiter dans une petite implementation d’exemple :
async function hide(node, options = {}) {
const hasTransition = options.transition
? new Promise((done) => node.addEventListener(
'transitionend', done, { once: true }))
: Promise.resolve()
// Etape 1: Assigner la class pour l’invisibilité douce
node.classList.add(option.className || `hide`)
// Etape 2: Attendre la fin des transitions et animations
await hasTransition
// Etape 3: Passer en invisibilité dure si nécessaire
if (!options.soft) {
node.hidden = true
node.style.display = 'none'
}
}
Il y aurait quelques précautions à prendre dans ce code (a minima un timeout sur l’attente des transitions) mais vous avez l’essentiel de ce qui doit se passer pour masquer un élément correctement.
Arrêtons-nous tout de même un peu sur l’étape trois qui pourrait sembler superflue mais qui ne l’est pas tant que ça. Déjà, du point de vue des performances, un élément en display:none
sera généralement ignoré par les navigateurs et il n’aura aucun impact en cas de calcul de reflow. Ce n’est pas le cas pour les éléments avec une invisibilité douce car même si on ne les voit pas, les navigateurs les prendront en compte dans leurs calculs (jusqu’à un certain point). Du point de vue de l’accessibilité un état invisible dur permet d’éviter un problème assez sérieux : la prise de focus sur un élément invisible 😱 et sa restitution vocale intempestive. C’est pour ces raisons que la suppression de l’étape trois doit être explicite (options.soft
) dans ce code d’exemple.
Un coup tu me vois 🐵
Afficher un élément invisible est un peu plus délicat, surtout si on veut pouvoir faire une transition douce. Les étapes pour faire apparaître un élément sont donc un peu plus compliquées :
- Appliquer une classe CSS (ou modifier une propriété de l’attribut
style
dans le DOM) qui représente un état invisible doux. - Supprimer l’attribut
hidden
et ledisplay:none
de l’élément - Attendre que le navigateur calcul le reflow de la page
- Supprimer la classe CSS (ou modifier la propriété de l’attribut
style
modifiées à l’étape 1) qui représente un état invisible doux. - Attendre la fin d’une éventuelle transition CSS
Note: Si l’élément n’est pas dans le DOM, il faut commencer par lui donner un display:none
avant de l’attacher à l’arbre DOM. On peut alors suivre les étapes qu’on a décrites.
Comme on le voit, c’est beaucoup moins intuitif, en particulier les étapes un et trois. L’étape un est nécessaire pour avoir la certitude que l’état invisible doux sera celui à la sortie de l’étape trois. Si on inverse les étapes un et deux rien ne garantit qu’on n’aura pas un reflow intempestif entre les deux, ce qui donnerait un rendu visuel des plus bizarres (un élément qui apparaît puis qui disparaît pour finalement réapparaître… je vous laisse imaginer le massacre s’il y a des transitions CSS au milieu 🤪)
Si vous avez suivi ce qu’on a dit jusque-là, vous devez comprendre la nécessité de l’étape trois… et vous allez voir c’est beaucoup plus simple à faire que ce qu’on pourrait croire (spoiler alert, on va faire un tour gratuit de l’event loop JavaScript). Voyons donc là aussi une petite implémentation d’exemple :
async function show(node, options = {}) {
const hasTransition = options.transition
? new Promise((done) => node.addEventListener(
'transitionend', done, { once: true }))
: Promise.resolve()
// Step 1: Assigner la class pour l’invisibilité douce
node.classList.add(option.className || `hide`)
// Step 2: Passer de l'invisibilité dure à la douce
node.hidden = false
node.style.display = '' // CSS reprend le control de display
// Step 3: Attendre que le reflow soit complet
await new Promise(requestAnimationFrame)
// Step 4: Retirer la class pour l’invisibilité douce
node.classList.remove(option.className || `hide`)
// Step 5: Attendre la fin des transition
await hasTransition
}
Que se passe-t-il exactement ? Dans notre code, les étapes un et deux sont des modifications du DOM. Lorsque ces lignes sont exécutées, le navigateur sait qu’il va devoir calculer un reflow. Cependant, le navigateur est malin et ne calcule pas immédiatement un reflow pour chaque ligne (ce serait beaucoup trop coûteux). Il va attendre qu’on ait fini de modifier le DOM avant de faire ses calculs. Et c’est exactement ce qui se passe à l’étape trois, nous disons au navigateur qu’on attend le résultat d’une promesse. À cet instant, le navigateur sait qu’il a une chance de faire ses calculs car on est en train d’attendre le résultat d’un calcul asynchrone. Pour être sûr qu’on a bien notre reflow, nous allons faire un tour de l’event loop JavaScript avec requestAnimationFrame
. Je ne rentre pas dans le détail du fonctionnement de l’event loop (allez voir les conf de Philip Robert et Jake Archibald sur le sujet) mais en gros, au moment où vous planifiez une micro-tâche avec await new Promise(requestAnimationFrame)
, on a la garantie que le navigateur attendra d’avoir fini ses calculs de reflow avant de résoudre la promesse et de reprendre l’exécution de notre fonction.
Conclusion
Et voilà ! On a vu tout ce qu’il faut savoir pour comprendre comment les navigateurs affichent et masquent les éléments. Cependant au quotidien c’est assez rare que l’on ait à gérer ça soi-même. Tous les frameworks modernes sont outillés pour régler cette question de manière plus ou moins transparente. Par exemple, React avec le module react-transition-group, Angular avec @angular/animations, VueJS avec les composants transition et transition-group, etc…
Cependant, plus vous comprendrez comment se comportent les navigateurs, plus il sera facile de comprendre ce que font vos outils et plus vous vous rendrez compte que certains outils ne sont pas si utiles que ça 😉