CSS Deep-Dive : matrix3d() pour une barre de défilement personnalisée parfaite à l'image

Les barres de défilement personnalisées sont extrêmement rares, principalement parce qu'elles sont l'un des éléments restants sur le Web qui ne peuvent pratiquement pas être stylisés (je vous regarde, sélecteur de date). Vous pouvez créer la vôtre avec JavaScript, mais cela est coûteux, de faible fidélité et peut sembler lent. Dans cet article, nous allons exploiter des matrices CSS non conventionnelles pour créer un scroller personnalisé qui ne nécessite aucun code JavaScript pendant le défilement, mais seulement du code de configuration.

TL;DR

Vous ne vous souciez pas des détails ? Vous souhaitez simplement regarder la démo du chat Nyan et obtenir la bibliothèque ? Vous trouverez le code de la démonstration dans notre dépôt GitHub.

LAM;WRA (Long and mathematical; will read anyways)

Il y a quelque temps, nous avons créé un défilement parallaxe (avez-vous lu cet article ? C'est vraiment bien, ça vaut le coup !). En repoussant les éléments à l'aide de transformations CSS 3D, les éléments se déplaçaient plus lentement que la vitesse de défilement réelle.

Récapitulatif

Commençons par un rappel du fonctionnement du défilement en parallaxe.

Comme indiqué dans l'animation, nous avons obtenu l'effet de parallaxe en poussant les éléments "en arrière" dans l'espace 3D, le long de l'axe Z. Le défilement d'un document est en fait une translation sur l'axe Y. Ainsi, si nous faisons défiler la page vers le bas de 100 pixels, chaque élément sera traduit vers le haut de 100 pixels. Cela s'applique à tous les éléments, même ceux qui sont "plus éloignés". Mais comme ils sont plus éloignés de la caméra, leur mouvement observé à l'écran sera inférieur à 100 px, ce qui génère l'effet de parallaxe souhaité.

Bien sûr, déplacer un élément vers l'arrière dans l'espace le fait également paraître plus petit, ce que nous corrigeons en redimensionnant l'élément. Nous avons déterminé les calculs exacts lorsque nous avons créé le sélecteur de défilement parallaxe. Je ne vais donc pas répéter tous les détails.

Étape 0: Que voulons-nous faire ?

Barres de défilement. C'est ce que nous allons créer. Mais avez-vous déjà vraiment réfléchi à ce qu'ils font ? Je ne l'ai certainement pas fait. Les barres de défilement indiquent la quantité de contenu disponible actuellement visible et la progression que vous avez effectuée en tant que lecteur. Si vous faites défiler la page vers le bas, la barre de défilement le fait également pour indiquer que vous progressez vers la fin. Si tout le contenu s'affiche dans la fenêtre d'affichage, la barre de défilement est généralement masquée. Si le contenu fait deux fois la hauteur de la fenêtre d'affichage, la barre de défilement occupe la moitié de la hauteur de la fenêtre d'affichage. Un contenu trois fois plus haut que la fenêtre d'affichage étale la barre de défilement sur un tiers de la fenêtre d'affichage, etc. Vous voyez le schéma. Au lieu de faire défiler la page, vous pouvez également cliquer et faire glisser la barre de défilement pour parcourir le site plus rapidement. C'est une quantité surprenante de comportements pour un élément discret comme celui-ci. Attaquons-nous à un problème à la fois.

Étape 1: Mettre la marche arrière

Nous pouvons faire en sorte que les éléments se déplacent plus lentement que la vitesse de défilement à l'aide de transformations CSS 3D, comme indiqué dans l'article sur le défilement parallaxe. Pouvons-nous également inverser la direction ? Il s'avère que nous pouvons le faire, et c'est notre entrée pour créer une barre de défilement personnalisée parfaite. Pour comprendre comment cela fonctionne, nous devons d'abord aborder quelques principes de base de la 3D CSS.

Pour obtenir n'importe quel type de projection perspective au sens mathématique, vous finirez probablement par utiliser des coordonnées homogènes. Je ne vais pas entrer dans les détails de ce qu'elles sont et pourquoi elles fonctionnent, mais vous pouvez les considérer comme des coordonnées 3D avec une quatrième coordonnée supplémentaire appelée w. Cette coordonnée doit être égale à 1, sauf si vous souhaitez créer une distorsion de perspective. Nous n'avons pas besoin de nous soucier des détails de w, car nous n'allons pas utiliser d'autre valeur que 1. Par conséquent, tous les points sont désormais des vecteurs à quatre dimensions [x, y, z, w=1], et les matrices doivent également être de 4 x 4.

Vous pouvez voir que le CSS utilise des coordonnées homogènes en interne lorsque vous définissez vos propres matrices 4x4 dans une propriété de transformation à l'aide de la fonction matrix3d(). matrix3d utilise 16 arguments (car la matrice est de 4 x 4), en spécifiant une colonne après l'autre. Nous pouvons donc utiliser cette fonction pour spécifier manuellement des rotations, des translations, etc., mais elle nous permet également de jouer avec la coordonnée w.

Avant de pouvoir utiliser matrix3d(), nous avons besoin d'un contexte 3D, car sans contexte 3D, il n'y aurait pas de distorsion de perspective et pas besoin de coordonnées homogènes. Pour créer un contexte 3D, nous avons besoin d'un conteneur avec un perspective et des éléments que nous pouvons transformer dans l'espace 3D nouvellement créé. Exemple:

Code CSS qui déforme un élément div à l'aide de l'attribut perspective du CSS.

Les éléments d'un conteneur en perspective sont traités par le moteur CSS comme suit:

  • Convertissez chaque coin (sommet) d'un élément en coordonnées homogènes [x,y,z,w], par rapport au conteneur de perspective.
  • Appliquez toutes les transformations de l'élément en tant que matrices de droite à gauche.
  • Si l'élément de perspective est à défilement, appliquez une matrice de défilement.
  • Appliquez la matrice de perspective.

La matrice de défilement est une translation sur l'axe Y. Si nous faisons défiler la page vers le bas de 400 px, tous les éléments doivent être déplacés vers le haut de 400 px. La matrice de perspective est une matrice qui "attire" les points vers le point de fuite plus ils sont éloignés dans l'espace 3D. Cela permet d'obtenir les deux effets suivants : les éléments apparaissent plus petits lorsqu'ils sont plus éloignés et ils "se déplacent plus lentement" lorsqu'ils sont traduits. Par conséquent, si un élément est repoussé, une translation de 400 px ne le déplacera que de 300 px à l'écran.

Si vous souhaitez connaître tous les détails, vous devez lire la spécification sur le modèle de rendu de transformation du CSS. Toutefois, pour les besoins de cet article, j'ai simplifié l'algorithme ci-dessus.

Notre zone se trouve dans un conteneur en perspective avec la valeur p pour l'attribut perspective. Supposons que le conteneur soit à défilement et qu'il soit défilé vers le bas de n pixels.

La matrice de perspective multipliée par la matrice de défilement multipliée par la matrice de transformation d'élément est égale à la matrice d'identité 4 x 4 avec moins un sur p dans la quatrième ligne, troisième colonne, multipliée par la matrice d'identité 4 x 4 avec moins n dans la deuxième ligne, quatrième colonne, multipliée par la matrice de transformation d'élément.

La première matrice est la matrice de perspective, la seconde est la matrice de défilement. Pour résumer: La matrice de défilement a pour fonction de faire déplacer un élément vers le haut lorsque nous défilons vers le bas, d'où le signe négatif.

Pour notre barre de défilement, nous voulons cependant l'inverse : nous voulons que notre élément descende lorsque nous faisons défiler l'écran vers le bas. Nous pouvons utiliser une astuce : inverser la coordonnée w des coins de notre boîte. Si la coordonnée w est -1, toutes les translations s'appliqueront dans la direction opposée. Comment procéder ? Le moteur CSS se charge de convertir les coins de notre cadre en coordonnées homogènes et définit w sur 1. C'est le moment de mettre matrix3d() en avant !

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

Cette matrice ne fait rien d'autre que d'annuler w. Ainsi, lorsque le moteur CSS a transformé chaque coin en vecteur de la forme [x,y,z,1], la matrice le convertit en [x,y,z,-1].

La matrice d'identité 4 x 4 avec moins un sur p dans la troisième colonne de la quatrième ligne multipliée par la matrice d'identité 4 x 4 avec moins n dans la quatrième colonne de la deuxième ligne multipliée par la matrice d'identité 4 x 4 avec moins un dans la quatrième colonne de la quatrième ligne multipliée par le vecteur à quatre dimensions x, y, z, 1 est égale à la matrice d'identité 4 x 4 avec moins un sur p dans la troisième colonne de la quatrième ligne, moins n dans la quatrième colonne de la deuxième ligne et moins un dans la quatrième colonne de la quatrième ligne est égale au vecteur à quatre dimensions x, y plus n, z, moins z sur p moins 1.

J'ai listé une étape intermédiaire pour montrer l'effet de notre matrice de transformation d'éléments. Si vous ne vous sentez pas à l'aise avec les calculs matriciels, ce n'est pas grave. Le moment Eureka est que, dans la dernière ligne, nous ajoutons le décalage de défilement n à notre coordonnée y au lieu de le soustraire. L'élément sera traduit vers le bas si vous faites défiler l'écran vers le bas.

Toutefois, si nous plaçons simplement cette matrice dans notre exemple, l'élément ne s'affichera pas. En effet, la spécification CSS exige que tout sommet avec w < 0 empêche l'élément d'être affiché. Et comme notre coordonnée z est actuellement 0 et que p est 1, w sera -1.

Heureusement, nous pouvons choisir la valeur de z. Pour nous assurer d'obtenir w=1, nous devons définir z = -2.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

Et voilà, notre boîte est de retour !

Étape 2: Faire bouger l'élément

Notre boîte est maintenant là et ressemble à ce qu'elle aurait été sans aucune transformation. Pour le moment, le conteneur de perspective n'est pas à faire défiler. Nous ne pouvons donc pas le voir, mais nous savons que notre élément ira dans l'autre sens lorsque le défilement sera effectué. Faisons défiler le conteneur. Nous pouvons simplement ajouter un élément d'espacement qui prend de la place:

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

Faites défiler la zone. L'encadré rouge descend.

Étape 3: Définissez une taille

Un élément se déplace vers le bas lorsque la page est défilée. C'est la partie difficile qui est terminée. Nous devons maintenant lui donner un style de barre de défilement et la rendre un peu plus interactive.

Une barre de défilement se compose généralement d'un "curseur" et d'une "piste", bien que la piste ne soit pas toujours visible. La hauteur du curseur est directement proportionnelle à la quantité de contenu visible.

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight correspond à la hauteur de l'élément à faire défiler, tandis que scroller.scrollHeight correspond à la hauteur totale du contenu à faire défiler. scrollerHeight/scroller.scrollHeight correspond à la fraction du contenu visible. Le ratio de l'espace vertical couvert par le curseur doit être égal au ratio du contenu visible:

La hauteur du point de style de point de la barre de défilement sur scrollerHeight est égale à la hauteur de la barre de défilement sur la hauteur de défilement du point de la barre de défilement si et seulement si la hauteur du point de style de point de la barre de défilement est égale à la hauteur de la barre de défilement multipliée par la hauteur de la barre de défilement sur la hauteur de défilement du point de la barre de défilement.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

La taille du pouce est correcte, mais il se déplace beaucoup trop vite. C'est là que nous pouvons récupérer notre technique à partir du défilement parallaxe. Si nous reculons l'élément, il se déplace plus lentement lors du défilement. Nous pouvons corriger la taille en l'agrandissant. Mais de combien exactement devons-nous le repousser ? Faisons un peu de calcul ! C'est la dernière fois, je vous promets.

L'information cruciale est que nous voulons que le bord inférieur du curseur se trouve en ligne avec le bord inférieur de l'élément à faire défiler lorsque le défilement est complètement vers le bas. En d'autres termes, si nous avons fait défiler scroller.scrollHeight - scroller.height pixels, nous voulons que le curseur soit traduit de scroller.height - thumb.height. Pour chaque pixel de la barre de défilement, nous voulons que notre pouce se déplace d'une fraction de pixel:

Le facteur correspond à la hauteur du point du conteneur de défilement moins la hauteur du point du curseur sur la hauteur de défilement du point du conteneur de défilement moins la hauteur du point du conteneur de défilement.

C'est notre facteur de scaling. Nous devons maintenant convertir le facteur de mise à l'échelle en translation le long de l'axe Z, comme nous l'avons déjà fait dans l'article sur le défilement parallaxe. Selon la section pertinente de la spécification : le facteur de scaling est égal à p/(p - z). Nous pouvons résoudre cette équation pour z afin de déterminer combien nous devons traduire notre pouce le long de l'axe Z. Toutefois, gardez à l'esprit qu'en raison de nos manipulations de coordonnées w, nous devons traduire un -2px supplémentaire le long de l'axe z. Notez également que les transformations d'un élément sont appliquées de droite à gauche, ce qui signifie que toutes les translations avant notre matrice spéciale ne seront pas inversées, mais que toutes les translations après notre matrice spéciale le seront. Codifions-le.

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

Nous avons une barre de défilement. Il ne s'agit que d'un élément DOM que nous pouvons styliser comme bon nous semble. En termes d'accessibilité, il est important de faire en sorte que le curseur réagisse au clic et au glissement, car de nombreux utilisateurs sont habitués à interagir avec une barre de défilement de cette manière. Pour ne pas alourdir cet article de blog, je ne vais pas expliquer les détails de cette partie. Pour en savoir plus, consultez le code de la bibliothèque.

Qu'en est-il d'iOS ?

Ah, mon vieil ami Safari pour iOS. Comme pour le défilement en parallaxe, nous rencontrons un problème ici. Comme nous faisons défiler un élément, nous devons spécifier -webkit-overflow-scrolling: touch, mais cela entraîne un aplatissement 3D et l'ensemble de notre effet de défilement cesse de fonctionner. Nous avons résolu ce problème dans le défilement en parallaxe en détectant Safari sur iOS et en nous appuyant sur position: sticky comme solution de contournement. Nous allons faire exactement la même chose ici. Consultez l'article sur le parallélisme pour rafraîchir votre mémoire.

Qu'en est-il de la barre de défilement du navigateur ?

Sur certains systèmes, nous devons faire face à une barre de défilement native permanente. Historiquement, la barre de défilement ne peut pas être masquée (sauf avec un pseudo-sélecteur non standard). Pour le masquer, nous devons donc recourir à quelques astuces (sans mathématiques). Nous encapsulerons notre élément de défilement dans un conteneur avec overflow-x: hidden et ferons en sorte que l'élément de défilement soit plus large que le conteneur. La barre de défilement native du navigateur n'est plus visible.

Fin

En rassemblant tout cela, nous pouvons maintenant créer une barre de défilement personnalisée au pixel près, comme celle de notre démo de chat Nyan.

Si vous ne voyez pas Nyan Cat, vous rencontrez un bug que nous avons détecté et signalé lors de la création de cette démonstration (cliquez sur le pouce pour faire apparaître Nyan Cat). Chrome est très efficace pour éviter les tâches inutiles, comme peindre ou animer des éléments en dehors de l'écran. La mauvaise nouvelle est que nos manigances matricielles font penser à Chrome que le GIF du chat Nyan est en fait hors écran. Nous espérons que ce problème sera résolu rapidement.

Voilà. C'était beaucoup de travail. Je vous félicite d'avoir lu l'intégralité de cet article. Il s'agit d'une véritable astuce pour que cela fonctionne, et cela ne vaut probablement que rarement la peine, sauf lorsqu'une barre de défilement personnalisée est un élément essentiel de l'expérience. Mais c'est bon de savoir que c'est possible, non ? Le fait qu'il soit si difficile de créer une barre de défilement personnalisée montre qu'il y a du travail à faire du côté du CSS. Mais ne vous inquiétez pas. À l'avenir, l'AnimationWorklet de Houdini facilitera grandement les effets liés au défilement tels que celui-ci.