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, et cela est principalement dû au fait que les barres de défilement sont l'un des éléments restants sur le Web qui ne sont pas vraiment stylables (je vous regarde, sélecteur de date). Vous pouvez créer la vôtre en JavaScript, mais cela est coûteux, de faible fidélité et peut sembler lent. Dans cet article, nous allons utiliser des matrices CSS non conventionnelles pour créer un conteneur de défilement personnalisé ne nécessitant aucun code JavaScript pour le défilement, mais uniquement du code de configuration.

TL;DR

Vous ne vous souciez pas des détails ? Vous souhaitez simplement consulter la démonstration de Nyan Cat 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 3D CSS, ils se sont déplacés 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 vers le bas de 100 pixels, par exemple, 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 trouvé la formule mathématique exacte lors de la création du défileur 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 point de départ pour créer une barre de défilement personnalisée au pixel près. Pour comprendre son fonctionnement, nous devons d'abord aborder quelques principes de base de la 3D CSS.

Pour obtenir n'importe quel type de projection de perspective au sens mathématique, vous vous retrouverez très probablement à 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 avoir une distorsion de la perspective. Nous n'avons pas à nous soucier des détails de w, car nous n'utiliserons pas 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 accepte 16 arguments (car la matrice est de 4x4), 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 ce contexte, il n'y aurait pas de distorsion de la 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 à l'intérieur d'un conteneur de perspective sont traités par le moteur CSS comme suit:

  • Transformez chaque angle (vertex) d'un élément en coordonnées homogènes [x,y,z,w], par rapport au conteneur de perspective.
  • Appliquer toutes les transformations de l'élément sous forme de matrices de droite à gauche.
  • Si l'élément de perspective est déroulant, 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 à la fois de réduire la taille des éléments lorsqu'ils sont plus éloignés et de ralentir la traduction. 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 les spécifications du modèle de rendu des transformations du CSS. Toutefois, pour les besoins de cet article, nous avons simplifié l'algorithme ci-dessus.

Notre cadre se trouve à l'intérieur d'un conteneur de perspective dont l'attribut perspective est défini sur la valeur p. Supposons que vous puissiez faire défiler le conteneur et faire défiler l'écran de n pixels vers le bas.

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. Une astuce consiste à inverser la coordonnée W des angles de la 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 angle en un 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é. Puisque notre coordonnée z est actuellement égale à 0 et que p est égale à 1, w est égal à -1.

Heureusement, nous pouvons choisir la valeur de z. Pour nous assurer que nous nous retrouvons avec 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. Le cadre rouge est déplacé vers le bas.

Étape 3: Indiquez une taille

Nous avons un élément qui descend lorsque la page fait défiler vers le bas. C'est la partie difficile qui est terminée. Nous devons maintenant styliser l'image pour qu'elle ressemble à une 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 est 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 ici que nous pouvons récupérer notre technique dans le conteneur de 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 de combien nous avons besoin pour 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 allonger encore davantage cet article de blog, je ne vais pas vous donner plus de détails. Pour savoir comment procéder, 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 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 gérer 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 de piratage (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 du chat Nyan.

Si vous ne voyez pas le chat Nyan, cela signifie que vous rencontrez un bug que nous avons trouvé et signalé lors de la création de cette démonstration (cliquez sur le pouce pour faire apparaître le chat Nyan). 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. J'espère que ce problème sera bientôt résolu.

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 faire fonctionner cela et l'effort en vaut probablement rarement la peine, sauf lorsqu'une barre de défilement personnalisée est une partie essentielle 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'outil AnimationWorklet d'Houdini va faciliter considérablement les effets de défilement parfaits pour des frames comme celui-ci.