En finir avec la barre de défilement horizontale et les unités de viewport

J’ai l’habitude de développer sur Mac OS, et sur ses navigateurs (que ce soit Chrome, Firefox ou Safari), la barre de défilement s’affiche par-dessus la page web et est invisible par défaut. C’est joli…

Jan 26, 2025 - 16:16
 0
En finir avec la barre de défilement horizontale et les unités de viewport

J’ai l’habitude de développer sur Mac OS, et sur ses navigateurs (que ce soit Chrome, Firefox ou Safari), la barre de défilement s’affiche par-dessus la page web et est invisible par défaut. C’est joli mais pas très pratique pour un·e développeur·euse. En effet on a vite fait de passer à côté d’une vilaine barre de défilement horizontale.

C’est ce qu’il s’est passé sur un site sur lequel j’ai travaillé récemment. En intégrant le nouveau menu du site, j’ai créé, sans m’en rendre compte, une barre de défilement horizontale sur le site. Elle n'était d'ailleurs pas juste invisible par défaut, mais totalement absente sur Mac OS alors que bien visible sur Windows. La plupart des personnes travaillant sur le projet étant sur Mac OS, on est un peu toutes et tous passé·e·s à côté.

Après m’être auto-flagellée, j’ai donc investigué sur le problème avant de me rendre compte que c’était l’utilisation d’une règle css width: 100vw; qui faisait apparaître cette barre. L'occasion de creuser un peu plus le sujet de ce bug, pas si anodin.

Le problème avec les unités de viewport

Les unités de viewport sont des unités relatives qui représentent un pourcentage de la taille du viewport. Ainsi 1vh représente 1% de la hauteur du viewport. Bien pratique pour afficher une section de type "hero" par exemple qui prendrait toute la hauteur de l'écran. Cependant un bug bien connu sur mobile a longtemps embêté les intégrateur·ice·s. La zone représentée par 100vh déborde de la zone visible lors de l'affichage de la barre d'adresse ou des éléments d'UI du navigateur.

L'arrivée de nouvelles unités a permis de corriger ce problème. Désormais les unités svh, lvh ou dvh représentent respectivement la petite zone d'affichage (avec la barre d'adresse), la grande zone d'affichage (sans barre d'adresse) ou dynamiquement la petite ou grande zone en fonction de celle qui correspond à la zone visible au-dessus de la ligne de flottaison. Plus de problème donc de contenu qui "déborde". En appliquant une hauteur de 100dvh (ou 100 svh) à un élément, on est sûrs qu'il ne dépassera jamais de la ligne de flottaison.

Cependant, on a un autre souci avec les unités de viewport. Celui-ci concerne cette fois-ci la largeur d'écran (vw). La taille 100vw représente 100% de la largeur du viewport sauf dans un cas : lorsque le site possède une barre de défilement verticale, 100vw représente alors la largeur du viewport + la largeur de la barre de défilement. Si on définit la largeur d'un élément à 100vw, celui-ci dépassera donc en largeur dans le cas où le site possède une barre de défilement verticale.

On comprend mieux pourquoi le problème n'est pas présent sur Mac OS : la barre s'affichant en superposition de la page, elle ne s'ajoute pas à la valeur de 100vw. Le problème ne survient que si on a une barre classique qui s'affiche dans une gouttière à côté du viewport.

On pourrait penser que les nouvelles unités svw, lvw et dvw nous permettraient de corriger ce problème mais ce n'est malheureusement pas le cas. Il y a eu de nombreux débats sur ce sujet sur le dépôt du CSS Working Group mais la difficulté est qu'il faudrait attendre que la page soit chargée pour savoir si celle-ci possède une barre verticale. Seulement dans ce cas, il faudrait soustraire la largeur de la barre de la largeur du viewport. Cela impliquerait donc de recalculer les styles à la fin du chargement si on a finalement une barre verticale.

Les différentes solutions au problème

Maintenant que l'on sait, et que l'on comprend, pourquoi on obtient une barre horizontale en utilisant 100vw, comment corriger ce problème ? Je vous entends déjà me dire "on n'a qu'à rajouter un overflow-x: hidden des familles et hop c'est réglé" (si si, je vous entends). Mais je vous arrête tout de suite : ici pas de solution cache misère. Certes, cela fonctionnerait mais on va voir comment éviter ce problème plutôt que de le masquer.

La première étape, si vous êtes sur MacOS, c'est d'activer l'affichage des barres de défilement pour obtenir des barres classiques comme sur Windows. Cela vous évitera de passer à côté du problème que tous les utilisateur·rice·s de Windows auront sur votre site. Pour cela, rendez vous dans Réglages Système > Apparence puis sélectionnez "toujours" pour l'option "Afficher les barres de défilement".

La solution évidente : n'utilisez pas 100vw

Ça parait évident vu comme ça, mais effectivement on a finalement rarement besoin d'utiliser la valeur 100vw. Il suffit parfois de remplacer l'utilisation des unités de viewport par les pourcentages. Essayez simplement de remplacer 100vw par 100%. Il y a cependant un cas où cela ne fonctionnera pas. Si vous avez un élément enfant inclus dans un parent qui a une largeur maximale. À moins que cet enfant soit positionné en absolute (et que le parent soit en position static), lui donner une largeur de 100% lui fera prendre la largeur de son parent et non la largeur du viewport.

Une solution, que vous avez peut-être déjà utilisée, consiste à appliquer une marge négative à droite et à gauche de l'élément.

.container {
  max-width: 1024px;
  margin-left: auto;
  margin-right: auto;
}

.full-element {
  margin-left: calc(-50vw + 50%);
  margin-right: calc(-50vw + 50%);
}

Cependant cette solution repose aussi sur les viewport width et causera également l'apparition d'une barre de défilement horizontale. À moins de pouvoir changer le balisage de votre page (ce qui n'était pas mon cas), il va falloir trouver une autre solution.

La solution classique avec JavaScript

La première solution consiste à utiliser JavaScript. Je sais, on aimerait mieux éviter, mais cette solution a au moins le mérite de fonctionner partout et dans tous les cas. La solution consiste à calculer la taille de la barre de défilement et à la soustraire à la valeur 100vw. Je m'explique.

On obtient la largeur de la barre de défilement de la façon suivante :

const scrollbarWidth = window.innerWidth - document.body.clientWidth

Si le navigateur n'affiche pas de barre verticale, scrollbarWidth vaudra 0. Lors du chargement de la page et de son redimensionnement, on compare window.innerWidth et document.body.clientWidth. S'ils sont égaux, cela signifie qu'on n'a pas de barre de défilement. S'ils sont différents, on peut alors calculer la valeur de scrollbarWidth. On affecte ensuite la valeur de scrollbarWidth à une custom property CSS :

const getScrollBarWidth = () => {
  if (window.innerWidth !== document.body.clientWidth) {
    const scrollbarWidth = window.innerWidth - document.body.clientWidth;
    document.body.style.setProperty("--scrollbar-width",  `${scrollbarWidth}px`);
  }
}

// On appelle la fonction au chargement
getScrollBarWidth();
new ResizeObserver(() => {
  // On la rappelle lors du redimensionnement
  getScrollBarWidth();
}).observe(document.documentElement);

Enfin, on peut utiliser cette custom property pour calculer la bonne valeur de vw en CSS :

--vw: calc(100vw - var(--scrollbar-width, 0));

.full-element {
  width: calc(var(--vw) * 100);
}

La solution sans JavaScript avec les container queries

La deuxième solution consiste à utiliser seulement du CSS (youpi), avec les container queries. Pourquoi je ne vous ai pas présenté cette solution en premier ? Parce que celle-ci n'est pas sans risques (oui, ça sent le vécu). On va pour cela faire en sorte que notre body soit un conteneur de type inline-size, ce qui correspond, en règle générale, à la largeur (je vous renvoie à l'article sur l'adaptation d'un site aux différentes langues pour comprendre ce que sont les valeurs logiques comme inline et block). Avec les container queries viennent de nouvelles unités. L'unité cqw (pour container query width) permet d'appliquer une largeur qui représente un pourcentage de la largeur du conteneur. On appliquera donc à notre élément une largeur de 100cqw.

.body {
  container-type: inline-size;
}

.full-element {
  width: 100cqw;
}

Et... tada