Contenu principal

Utiliser les Scroll Driven Animations en CSS comme indicateur de scroll

lundi 04 décembre 2023

L’autre jour je commençais à vous parler des Scroll Driven Animations en animant un header sticky. Aujourd’hui on va continuer à explorer cette nouvelle techno CSS en l’utilisant comme indicateur.

Notamment, j’ai un truc qui m’embĂȘte rĂ©guliĂšrement sur le web : comment est-ce que je suis censĂ© savoir qu’un contenu est scrollable ou non. C’est un problĂšme d’autant plus rĂ©current que les scrollbars ont tendance Ă  disparaĂźtre, Ă©tant donnĂ© que sur mobile on cherche Ă  tout prix Ă  Ă©conomiser de la place.

Pour rĂ©soudre ce problĂšme, j’ai remarquĂ© 3 types de solutions :

  • L’icone mystĂ©rieux : un petit icone, parfois animĂ© pour pousser les gens Ă  regarder plus loin

    Schema d'un carousel horizontal avec un bouton sur la droite pour indiquer qu'il y a plus d'éléments
  • Le boucher du web : on s’assure que le contenu suivant est partiellement visible pour indiquer qu’il y a en a plus

    Schema d'un carousel horizontal oĂč le troisĂšme Ă©lĂ©ment est brutalement coupĂ©
  • Le dĂ©gradĂ© mystĂ©rieux : on gĂšne lĂ©gĂšrement la lecture avec un petit dĂ©gradĂ© pour pousser Ă  aller voir la suite sans avoir une coupure trop nette

    Schema d'un carousel horizontal oĂč le troisĂšme Ă©lĂ©ment n'est pas entiĂšrement visible et s'Ă©vanouit dans un dĂ©gradĂ© blanc

Aujourd’hui on va se concentrer sur cette 3Ăšme solution pour laquelle les Scroll Driven Animations semblent ĂȘtre la solution idĂ©ale : uniquement en CSS et trĂšs fluide Ă©tant donnĂ© que ce n’est plus exĂ©cutĂ© sur le main thread.

PrĂ©requis : Avant de lire cet article, je vous conseille de lire le paragraphe Comment fonctionne une Scroll Driven Animation : il prĂ©sente le concept et pose les bases pour avoir un modĂšle mental de comment ça fonctionne. Seul ce paragraphe est vraiment obligatoire. Le reste de l’article (bien que trĂšs intĂ©ressant 😁) n’est pas nĂ©cessaire.

Attention : A ce jour, ce que je vais vous prĂ©senter n’est disponible que sur les derniĂšres versions de Chrome (caniuse). Evitez donc de l’utiliser en production pour le moment ou Ă  minima, pensez Ă  utiliser @supports (animation-timeline: view()) {} pour Ă©viter des mauvaises surprises sur les autres navigateurs.

TL;DR

Vous pouvez accéder au code et à la démo ici : https://codepen.io/julienpradet/pen/gOqObbW

L’objectif est triple ici :

  • animer l’opacitĂ© de chaque Ă©lĂ©ment en fonction de sa position dans le scroll
  • ne pas rĂ©duire l’opacitĂ© si on est tout en haut du scroll : Ă©tant donnĂ© que l’opacitĂ© rĂ©duite indique le reste Ă  scroll, on veut qu’elle soit Ă  100% si on est tout en haut.
  • idem quand on est tout en bas du scroll

Vous constaterez par ailleurs que je n’ai pas utilisĂ© de dĂ©gradĂ© et que je me suis contentĂ© d’opacitĂ© parce que ça m’a eu l’air suffisant et plus joli. Mais le code pourrait ĂȘtre adaptĂ© si vous prĂ©fĂ©rez un dĂ©gradĂ©.

0. Structure générale de notre HTML

Pour cet exercice, nous faire un exemple minimaliste de chat avec une boĂźte qui est scrollable verticalement, et Ă  l’intĂ©rieur de celle-ci une liste de messages.

<div class="scroll-container">
	<p class="message me">Hey!</p>
	<p class="message you">Hey!</p>
	<p class="message me">What's up?</p>
	<p class="message you">All good, you?</p>
	<p class="message me">All good.</p>
</div>
.scroll-container {
	max-height: 500px;
	overflow-y: auto;
}

.message {
	padding: 1rem;
	background: var(--color-primary);
}

1. DĂ©finir l’opacitĂ© en fonction de la position d’un Ă©lĂ©ment

Une fois la structure de base prise en compte, nous allons commencer Ă  faire changer l’opacitĂ© en fonction de la position de l’élĂ©ment dans l’écran.

.message {
	animation: y-distribution; /* 2 */
	animation-timeline: view(); /* 1 */
}

@keyframes y-distribution {
	0% {
		opacity: 0.3; /* 3 */
	}

	25% /* 4 */ {
		opacity: 1;
	}

	/* 5 */
	75% {
		opacity: 1;
	}

	100% {
		opacity: 0.3;
	}
}
  1. animation-timeline permet de savoir quel genre d’animation on souhaite effectuer. Quand on pense Scroll Driven Animation, on va avoir tendance Ă  penser Ă  animation-timeline: scroll() qui permet de dire : si on est tout en haut du scroll, l’animation est Ă  0%, et tout en bas, Ă  100%. Mais ici, ce qui nous intĂ©resse ce n’est pas l’opacitĂ© du premier et du dernier Ă©lĂ©ment de tout le scroll, mais uniquement le premier et le dernier Ă©lĂ©ment visible. C’est pour ça qu’on utilise animation-timeline: view()
  2. on choisit quelle animation executer : en l’occurrence ce sera y-distribution qui se dĂ©finit avec @keyframes comme pour une animation normale.
  3. on ne veut pas complÚtement cacher le premier élément, donc on part de 0.3 quand il est tout en haut de la partie visible
  4. Puis on le rend totalement visible Ă  partir de 25%. Ce pourcentage dĂ©pend de la hauteur de votre scroll-container : s’il est trop bas, vous risquez de tomber dans des situations oĂč l’opacitĂ© n’est pas du tout appliquĂ©e ; s’il est trop haut, tous vos messages seront en opacitĂ© rĂ©duite.
  5. Puis on refait la mĂȘme chose dans le sens inverse : plus on est proche du bas, plus l’opacitĂ© est rĂ©duite

Ca fonctionne Ă  merveille, mais on a pour l’instant juste une animation d’entrĂ©e et de sortie : si on est tout en haut, le premier message est Ă  0.3 d’opacitĂ© et donc peu lisible.

2. Prendre en compte la proximité avec le haut du scroll

Nous allons donc devoir adapter notre CSS pour s’assurer que l’opacitĂ© est de 1 quand on est tout en haut du scroll.

Le CSS pourrait alors ressembler à ça :

@property --min-opacity {
    syntax: "<number>";
    inherits: true;
    initial-value: 1;
}

@keyframes top-proximity; {
	0% {
		--min-opacity: 1; /* 3 */
	}

	100% {
		--min-opacity: 0;
	}
}

.message {
	animation: top-proximity;
	animation-timeline: scroll(); /* 1 */
    animation-range: 0% 3rem; /* 2 */
}
  1. Ce qui nous intĂ©resse c’est la hauteur totale du scroll, donc on repasse Ă  une animation-timeline: scroll()
  2. On ne peut plus faire notre @keyframes sur toute la hauteur du scroll : sinon, quand on a 100 messages, l’animation met trop de temps Ă  se jouer sur le scroll. On ajoute donc une animation-range: 0% 3rem pour indiquer que l’animation se jouera uniquement sur 3rem de hauteur. Ainsi, l’animation est Ă  0% si on est tout en haut, 50% si on a scrollĂ© 1.5rem, 100% si on a scrollĂ© 3rem et plus.
  3. Pendant l’animation, on va faire Ă©voluer la propriĂ©tĂ© --min-opacity : si on est tout en haut, alors on ne devrait pas diminuer l’opacitĂ© en lui dĂ©finissant un minimum de 1 ; sinon, elle peut Ă©voluer normalement (donc pas de minimum en la mettant Ă  0)
  4. Pour que cette propriĂ©tĂ© CSS soit animable, on doit dĂ©finir au navigateur comment l’animer : on anime pas de la mĂȘme façon un nombre et une couleur. C’est pour cette raison qu’on aide le navigateur en dĂ©finissant une @property

C’est bien beau tout ça, mais si on met Ă  jour notre code, ça ne marche plus : en effet, on a retirĂ© l’animation initiale et le --min-opacity n’est utilisĂ©e nulle part.

Nous allons donc devoir combiner les 2 méthodes ensemble. Pour ce faire, nous pouvons chaßner les animations dans animation-timeline et animation-range en les séparant par des virgules.

/* 1 */
@property --opacity {
    syntax: "<number>";
    inherits: true;
    initial-value: 1;
}

@property --min-opacity {
    syntax: "<number>";
    inherits: true;
    initial-value: 1;
}

@keyframes top-proximity; {
	0% { --min-opacity: 1; }
	100% { --min-opacity: 0; }
}

@keyframes y-distribution {
	0% { --opacity: 0.3; }
	25% { --opacity: 1; }
	75% { --opacity: 1; }
	100% { --opacity: 0.3; }
}

.message {
	animation:
        y-distribution,
        top-proximity;
	animation-timeline:
        view(),
        scroll();
    animation-range:
        normal,
        0% 3rem;
    /* 2 */
    opacity: max(--min-opacity, --opacity);
}
  1. PlutĂŽt que d’animer l’opacity dans l’animation y-distribution on va prĂ©fĂ©rer mettre Ă  jour une nouvelle @property qu’on va appeler --opacity
  2. Cela nous permet ainsi de calculer l’opacity finale à partir de --min-opacity et --opacity

Cette fois-ci, le résultat est meilleur : si on est tout en haut le premier message est entiÚrement visible. Si on commence à scroller, les messages du haut voient leur opacité diminuer.

3. Prendre en compte la proximité avec le bas du scroll

Cependant, nous avons un problĂšme : quand on est tout en haut, le dernier message visible est lui aussi complĂštement visible. C’est un problĂšme parce qu’au tout dĂ©but du scroll, on ne voit plus qu’il y a une suite.

Nous allons donc voir comment utiliser la proximitĂ© avec le haut et avec le bas. Pour cela, nous allons devoir reproduire ce qu’on a fait pour le top-proximity mais cette fois ci avec un bottom-proximity.

@keyframes top-proximity; {
	0% { --min-opacity: 1; }
	100% { --min-opacity: 0; }
}

@keyframes bottom-proximity; {
	0% { --min-opacity: 1; }
	100% { --min-opacity: 0; }
}

@keyframes y-distribution {
	0% { --opacity: 0.3; }
	25% { --opacity: 1; }
	75% { --opacity: 1; }
	100% { --opacity: 0.3; }
}

Cependant, top-proximity et bottom-proximity rentrent en conflit, on ne sait pas vraiment lequel doit gagner. On va donc plutĂŽt les renommer en --top-min-opacity et --bottom-min-opacity et en fonction de oĂč on est dans le scroll, on va utiliser soit l’un, soit l’autre.

Si nous étions en JavaScript, nous pourrions faire une condition qui ressemblerait à :

.message {
	opacity: max(
        var(--opacity),
        /* Ceci ne fonctionne pas */
        var(--is-top) ? var(--top-min-opacity): var(--bottom-min-opacity)
    );
}

Mais nous ne sommes pas en JS. Donc comment transformer ça en utilisant uniquement des calculs ?

L’astuce est de constater qu’une min-opacity qu’on veut ignorer devrait ĂȘtre Ă  0. En effet, max(0, var(--opacity)) === var(--opacity).

De fait, si on veut ignorer --top-min-opacity quand on est dans la partie basse de l’écran, il faut alors la multiplier par 0. Si on est dans la partie haute, on peut la conserver Ă  sa valeur initiale et donc la multipler par 1.

Ceci nous donne : --min-opacity: calc(var(--is-top) * var(--min-top-opacity)) avec --is-top: 0 dans la partie haute, et --is-top: 1 dans la partie basse.

Conceptuellement c’est exactement comme si nous avions Ă©crit : --min-opacity: var(--is-top)? var(--top-min-opacity): 0.

Inversement, si on est en haut on devrait multiplier --min-bottom-opacity par 0, et par 1 si on est en bas. Autrement dit, on peut multiplier par l’inverse: 1 - var(--is-top).

Ce qui nous donne: --min-opacity: calc((1 - var(--is-top)) * var(--min-bottom-opacity))

Enfin, pour prendre les deux en compte en mĂȘme temps, on peut faire la somme des 2 : quand --is-top vaudra 0, c’est le --min-bottom-opacity qui gagnera, quand il vaudra 1 c’est le --min-top-opacity.

Ca nous donne la formule suivante :

.message {
	--min-opacity: var(--is-top) * var(--top-min-opacity)
        + (1 - var(--is-top)) * var(--bottom-min-opacity);

	opacity: max(var(--opacity), var(--min-opacity));
}

💡 Astuce : La bonne nouvelle c’est que cette mĂ©thode marche avec un nombre infini d’indicateurs : en effet, plutĂŽt que de faire 1 - var(--is-top) vous auriez pu crĂ©er un nouveau --is-bottom. Donc en appliquant cette technique, vous pourriez aller jusqu’à implĂ©menter un switch en CSS.

Maintenant la question est : comment dĂ©finir ce --is-top ? En rĂ©utilisant nos @keyframes :

+@property --is-top {
+    syntax: "<number>";
+    inherits: true;
+    initial-value: 1;
+}

@keyframes y-distribution {
	0% { --opacity: 0.3; }
	25% { --opacity: 1; }
+	50% { --is-top: 1; }
+	51% { --is-top: 0; }
	75% { --opacity: 1; }
	100% { --opacity: 0.3; }
}

Et voila! Nous avons maintenant un comportement de scroll qui :

  • diminue l’opacitĂ© quand .message est au bord de la vue
  • sauf si on est tout en haut du scroll
  • ou si on est tout en bas du scroll

Conclusion

Ainsi, on sait toujours quand il y a plus de contenu cacher derriĂšre le scroll et surtout c’est trĂšs lĂ©ger pour le navigateur vu que c’est fait uniquement en CSS. Attention toutefois Ă  garder en tĂȘte que ce n’est disponible que sur les Chromium pour l’instant, mais que ce standard se rĂ©pandre au fur et Ă  mesure.

Si vous en avez besoin dĂšs aujourd’hui, sĂ»rement qu’une alternative JS sera plus pertinente. (ex: GSAP)

Et vous, vous auriez fait comment ? Je serais curieux d’en savoir plus.

En tout cas, si c’est le genre de choses qui vous intĂ©ressent, n’hĂ©sitez pas Ă  me suivre et Ă  partager autour de vous. Je publie rĂ©guliĂšrement des articles autour de la performance et du dĂ©veloppement web. A trĂšs vite 😘


Si vous voulez suivre mes publications, il paraĂźt que j'ai un feed RSS, Mastodon et un Twitter.

Si vous pensez à d'autres méthodes que vous voudriez que je mette en place (pigeon voyageur, avion en papier, etc.), n'hésitez pas à me les proposer :)