Éviter les layout shifts avec des grilles CSS empilées
Laisse-moi te raconter comment un petit bouton ℹ️ ajouté à une tuile de la console Clever Cloud s’est mis à décaler toute la page, et comment empiler ses deux états dans une même cellule de grille CSS a tout arrêté.
display: none
empilement grid
Il y a deux raisons d’utiliser les grilles en CSS :
- 😎 Le CSS, c’est la vie ! C’est un fait, deal with it.
- 🛠️ Les grilles, c’est un super outil pour construire des layouts complexes en deux dimensions.
Il m’arrive d’en avoir une troisième : éviter les layout shifts. J’ai essayé de trouver un acronyme qui claque pour cette technique et tout ce que j’ai sorti, c’est TALSEG : « Technique Anti Layout Shift par Empilement en Grille ». Je ne me fais pas d’illusions sur le nombre de recommandations de compétences que ça va me rapporter dans la catégorie « invention de termes techniques » sur LinkedIn, alors envoie-moi tes suggestions.
Laisse-moi t’expliquer la technique avec de vrais exemples. Dans cet article, je vais parler :
- du vrai problème de layout shift que j’ai rencontré sur un composant.
- des limites de la solution en positionnement absolu.
- des avantages de la solution en grilles.
Attends, c’est quoi un layout shift, au juste ?
Un layout shift, ou décalage de mise en page, c’est ce qui se passe quand une mise à jour dans une page web fait bouger quelque chose de façon significative. La plupart du temps, les layout shifts arrivent pendant le chargement de la page, pendant que le contenu s’affiche petit à petit. Par exemple, quand une image dont la hauteur n’est pas déclarée s’insère dans la page, juste avant le paragraphe que tu étais en train de lire, le texte s’enfuit et ça devient vite très pénible. C’est aussi très courant quand des scripts tiers (régies publicitaires ou contenus partenaires) viennent ajouter des éléments inconnus dans la page.
Cet article se concentre sur les layout shifts qui arrivent quand on change l’état d’un composant d’interface. Je ne parlerai pas de ceux qui arrivent pendant le chargement de la page.
Un peu de contexte
Je bosse chez Clever Cloud, une plateforme d’automatisation IT. Nos clients poussent leur code et on s’occupe du reste pour eux : build, déploiement, hébergement, scaling, maintenance, etc. Quand ils ont besoin de configurer leurs applications, leurs bases de données et leurs autres services, ils passent par notre interface web : « la console ». Je passe le plus clair de mon temps sur ce projet et ça ressemble à ça :

Ce que tu vois sur la capture ci-dessus, c’est la vue d’ensemble de mon propre site, une application Node.js, hébergée sur notre plateforme. À droite, il y a un camembert qui représente la répartition des codes de réponse HTTP renvoyés par l’application sur les dernières 24 heures.

Quand j’ai bossé sur ce composant, j’ai essayé de faire simple. Je me suis dit que nos utilisateurs comprendraient tout seuls que le graphique concerne l’application affichée. Pour désencombrer, j’ai déplacé certains détails, comme le nombre exact de requêtes, dans des tooltips accessibles à la souris ou au toucher. Une fois le graphique bien nettoyé, je me suis retrouvé avec un problème. Comment expliquer les points suivants ?
- Le graphique n’affiche que les données des dernières 24 heures.
- Chaque entrée de la légende est cliquable pour afficher ou masquer les différentes catégories de codes de statut. Merci Chart.js !
La place disponible était trop petite pour ajouter un titre détaillé en haut ou en bas du graphique. Alors, pour aider nos utilisateurs à s’y retrouver, j’ai ajouté un bouton d’information ℹ️ dans le coin en haut à droite. Quand on clique dessus, le graphique est masqué et un court texte s’affiche à la place. Ça donne ça :

Quand on clique sur le bouton de fermeture, le court texte disparaît et le graphique revient. Implémenter ce comportement de bascule n’avait rien de compliqué. Prenons une version simplifiée du template :
<div class="chart-component">
<div class="title">
HTTP response codes <button>toggle chart/info</button>
</div>
<div class="chart">
<!-- chart here -->
</div>
<div class="info">
<!-- short text here -->
</div>
</div>
Avec ce HTML, au clic sur le bouton, je peux simplement basculer l’état du composant entre .chart et .info, et masquer le panneau qui va bien avec display: none.
Les problèmes de layout shift
Cette approche a un gros problème : elle provoque des layout shifts.
Et pourquoi donc ?
Quand je bosse sur ce genre de composants, j’ai tendance à suivre ce que je considère comme une bonne pratique : ne pas fixer de hauteur sur le composant, le laisser s’adapter à son propre contenu. C’est encore plus important ici, parce que je n’ai aucun moyen de savoir combien de place il faut pour afficher le court texte (sans barre de défilement). La hauteur minimale nécessaire dépend :
- de la largeur du composant, inconnue, puisqu’elle dépend du contexte d’utilisation
- de la longueur du court texte, qui dépend de la langue (français ou anglais)
Résultat : le composant est un peu moins haut avec le graphique qu’avec le court texte.

Le problème est moins marqué en anglais, où le texte est un peu plus court :

Selon les cas, ce changement de hauteur, l’air de rien, peut avoir de gros impacts sur toute la mise en page et sur la position du scroll. Ce genre de détail peut rendre tes utilisateurs dingues :
- Au mieux, d’autres parties de la page bougent un peu.
- Au pire, le bouton sous le curseur se déplace et autre chose vient prendre sa place (coucou le champ de recherche de Twitter).
La vidéo ci-dessous montre ce qui se passe quand je bascule d’un état à l’autre.
On voit un premier layout shift quand je passe de « graphique » à « info ». Le graphique en barres descend et la carte grossit.
Maintenant, regarde ce qui se passe quand je scrolle plus bas dans la page et que je reviens à l’état « graphique ». Regarde bien le curseur de la souris. Une fois au-dessus du bouton, je ne le bouge plus, et pourtant un simple clic déclenche une réaction en chaîne :
- La hauteur du composant diminue pour afficher le graphique.
- La hauteur de toute la page diminue.
- Toute la page remonte un peu.
- Le curseur n’est plus au-dessus du bouton, il est au-dessus d’un texte.
Bon. Comment empêcher ce layout shift d’arriver ?
La solution en position absolue
Avant les grilles, j’aurais réglé ce problème avec la propriété CSS position.
D’abord, j’utiliserais visibility: hidden plutôt que display: none pour masquer le panneau « inactif » :

Comme tu peux le voir, avec visibility: hidden, le panneau « inactif » est masqué mais le composant calcule sa propre taille comme si les deux panneaux étaient là.
J’ai maintenant un composant à la hauteur stable, basée sur les hauteurs de .chart et de .info.
Ensuite, je mettrais position: absolute sur .chart.
Ça sort l’élément du flux normal.
Autrement dit, le composant calcule sa propre taille comme si .chart n’était pas là.
Du coup, sa taille dépendrait surtout de celle de .info, ce qui est exactement ce que je veux.
Il ne reste plus qu’à donner à .chart exactement la même position et la même taille qu’à .info.
Avec position: absolute, .chart est positionné « par rapport à son ancêtre positionné le plus proche ».
C’est-à-dire un ancêtre dont la propriété position a une autre valeur que celle par défaut (static).
La plupart du temps, je mets position: relative sur le parent pour déclencher ça.
Ici, il me faudrait un .wrapper supplémentaire autour des deux panneaux, comme ceci :
<div class="chart-component">
<div class="title">
HTTP response codes <button>toggle chart/info</button>
</div>
<div class="wrapper">
<div class="chart">
<!-- chart here -->
</div>
<div class="info">
<!-- short text here -->
</div>
</div>
</div>
Maintenant que j’ai ce HTML, la solution CSS ressemblerait à ça :
.wrapper {
/* .wrapper est l'ancêtre positionné le plus proche de .chart */
position: relative;
}
.chart {
/* .chart est sorti du flux normal, donc
.wrapper a exactement la même taille (et la même position) que .info
car .info est le seul enfant qui reste dans le flux normal
.chart est positionné par rapport à .wrapper */
position: absolute;
/* même position que .wrapper (donc même position que .info) */
left: 0;
top: 0;
/* même taille que .wrapper (donc même taille que .info) */
height: 100%;
width: 100%;
}
Cette solution en position: absolute me permet d’atteindre mon objectif.
La taille de mon composant dépendra toujours de celle de .info, même quand il est masqué et que .chart est visible.
🤔 Plus de layout shift au changement d’état, mais il reste quelques limites :
D’abord, il aurait fallu ajouter un .wrapper en position: relative juste pour pouvoir mettre position: absolute sur .chart.
CSS c’est génial, mais c’est aussi coriace à dompter.
Si le fonctionnement de la propriété position te donne encore du fil à retordre, cette approche peut faire peur.
Pas de panique :
- C’est normal. Tu n’es pas tout seul !
- Ça vient avec le temps et la pratique…
Ensuite, il aurait fallu partir du principe que la taille du composant ne dépendrait que de .info.
Dans des cas plus tordus, je pourrais avoir plus de deux panneaux, sans pouvoir deviner lequel doit guider la taille de l’ensemble.
Voyons maintenant comment les grilles s’en sortent mieux.
La solution en grilles
Les grilles sont supportées par tous les navigateurs evergreen maintenant. Ça veut dire qu’on peut compter sur le fait de pouvoir placer et superposer plusieurs éléments dans une même zone de grille.
Une zone de grille, c’est-à-dire ?
Si tu n’es pas encore à l’aise avec les grilles, avant de continuer cet article, je te conseille de :
- Regarder n’importe quelle conférence de Rachel Andrew. Elle est excellente, et dans cette vidéo récente avec Jason Lengstorf, ils expliquent les grilles avec des exemples en live coding.
- Garder en permanence un onglet ouvert sur le Complete Guide to Grid de CSS-Tricks.
- Pratiquer, pratiquer, pratiquer…
Maintenant que tu es plus à l’aise avec les grilles, on peut parler de la désormais célèbre « Technique Anti Layout Shift par Empilement en Grille » (désolé). Reprenons le template simple du début :
<div class="chart-component">
<div class="title">
HTTP response codes <button>toggle chart/info</button>
</div>
<div class="chart">
<!-- chart here -->
</div>
<div class="info">
<!-- short text here -->
</div>
</div>
Si on met une grille sur .chart-component comme ça :
.chart-component {
display: grid;
gap: 1rem;
}
Notre composant ressemble à ça :

Grâce au génial inspecteur de grille CSS des DevTools de Firefox, on a des annotations pour les colonnes et les rangées. On peut voir que :
.title,.chartet.infosont entre les colonnes 1 et 2.titleest entre les rangées 1 et 2.chartest entre les rangées 2 et 3.infoest entre les rangées 3 et 4
C’est le comportement par défaut dans une simple grille à une colonne : les enfants sont placés dans l’ordre.
Avec grid-column et grid-row, on peut forcer un élément à se placer dans une zone précise de la grille.
Par exemple, si on place nos deux éléments dans la même zone comme ça :
.chart,
.info {
grid-column: 1 / 2;
grid-row: 2 / 3;
}
Le résultat ressemble à ça :

Si tu es sceptique, souviens-toi que le bon panneau sera masqué avec un visibility: hidden.
Avec cette technique, on a en fait dit au moteur CSS de préparer une grille où la zone située à la colonne 1 / 2 et à la rangée 2 / 3 doit s’adapter à ce qu’elle contient.
Autrement dit : cette zone fera toujours la taille du plus grand des deux, .chart ou .info.
Du coup, la hauteur de tout le composant reste la même quand on bascule d’un état à l’autre.
😎 Plus de layout shift !
Par rapport à la solution en position absolue, on a gagné sur plusieurs points :
- Pas besoin d’ajouter un
.wrapper - Ça marcherait très bien avec plus de deux panneaux
- Pas besoin de deviner quel panneau doit guider la taille de tout le composant
Maintenant, en bonus, laisse-moi te montrer un autre vrai exemple où j’ai utilisé cette technique.
Empêcher les layout shifts horizontaux
L’exemple précédent portait sur la hauteur d’un composant, mais tu peux aussi utiliser cette technique sur la largeur. Si tu regardes à nouveau la vue d’ensemble de la console Clever Cloud, dans le coin en haut à droite, tu remarqueras quelques boutons pour piloter l’état de ton application.

Le bouton « Stop app » a des paddings horizontaux plus grands que les autres.
C’est parce qu’au clic sur ce bouton, le texte est remplacé par « Click to cancel » pendant 3 secondes, au cas où tu paniquerais changerais d’avis.
Si on n’utilisait pas la technique des grilles, quand le bouton passe de « Stop app » à « Click to cancel », le changement de largeur décalerait tous les autres boutons comme ça :

Sur les écrans étroits, le retour à la ligne des boutons peut déclencher un layout shift encore plus gros :
Grâce à la « Technique Anti Layout Shift par Empilement en Grille » (envoie tes idées de nom, je n’en peux plus), pas besoin de deviner quel état sera le plus large. Quelles que soient les longueurs du texte normal et du texte d’annulation, le bouton sera toujours assez grand.

😎 Plus de layout shift !
J’espère que cet article t’aura aidé à voir ce genre de layout shift comme un vrai problème à régler. Et j’espère aussi que cette technique te servira dans tes propres projets.
Références et liens
À propos de CSS et des grilles :
- La référence de la propriété CSS position sur MDN
- Le support de CSS Grid Layout dans les navigateurs sur Can I use
- Let’s Learn CSS Grid! (with Rachel Andrew) sur la chaîne YouTube de Jason Lengstorf
- Complete Guide to Grid sur CSS-Tricks
- Pense à utiliser Firefox et son génial inspecteur de grille CSS
Le composant camembert et le composant bouton font partie de la bibliothèque de composants de Clever Cloud. Le code est open source sur GitHub et la documentation (avec aperçu en direct) est publiée avec Storybook.
Tu peux en savoir plus sur les composants dont on a parlé ici :
- Chart.js : la bibliothèque JavaScript utilisée pour ces graphiques
<cc-tile-status-codes>: le composant camembert<cc-button>: le composant bouton avec le « click to cancel »
Merci
😍 Merci aux merveilleuses relectrices et merveilleux relecteurs pour le temps que vous m’avez accordé : Julien Durillon, Alexandre Berthaud, Anthony Ricaud, Sarah Haïm-Lubczanski, Julien Lengrand-Lambert et Ralf D. Müller.