Contenu principal

Comment faire un header sticky animé et performant ?

lundi 04 septembre 2023

En ce moment il y a deux nouvelles APIs qui sont en train d’arriver dans les navigateurs qui font fureur :

💡 Pour info ces APIs sont disponibles sur Chrome uniquement pour le moment. Mais dans les 2 cas, Webkit (= Safari) et Gecko (= Firefox) sont d’accord avec la proposition, donc ça va finir par arriver aussi.

C’est pour moi la preuve qu’il y a une rĂ©elle appĂ©tence pour avoir un web plus dynamique, plus animĂ©. Et personnellement, je pense que c’est l’unique raison pour laquelle, dans l’imaginaire collectif, les applications mobiles ont l’air plus quali que le web (et, accessoirement, la quantitĂ© affolante de pubs et de bandeaux de cookies).

Et preuve supplĂ©mentaire de cette appĂ©tence, j’ai rarement vu autant de devs front se mettre Ă  tester de nouvelles APIs, alors que ce n’est mĂȘme pas encore dispo sur tous les navigateurs.

Notamment, cette dĂ©mo de @jhey, disponible sur Codepen a vraiment attirĂ© mon attention. Les idĂ©es que j’aime beaucoup :

  • animer l’avatar au moment oĂč il passe en sticky ❀
  • rĂ©organiser le header pour qu’il prenne moins de place au passage en sticky

💡 Sticky : qui reste visible Ă  l’écran quelque soit le niveau de scroll

Ce sont ces petits moments qui me font des petits guilis au ventre et me rendent heureux quand je parcours le web.

Pour construire cette dĂ©mo, il a utilisĂ© une Scroll Driven Animation. Dans cet article, je vais vous prĂ©senter en quelques mots comment ça fonctionne. Cela dit, mon but principal est de me concentrer sur les raisons pour lesquelles je pense que ce n’est pas la technique la plus adaptĂ©e qui est utilisĂ©e ici, et comment on pourrait faire diffĂ©remment. Enfin, en toute fin d’article je partagerai quelques ressources oĂč Scroll Driven Animation permet rĂ©ellement d’amĂ©liorer l’état du web.

Comment fonctionne une Scroll Driven Animation ?

Avant de parler de Scroll Driven Animation, parlons d’animation tout court. En effet, si on revient Ă  la source de celle ci, il s’agit d’une maniĂšre de passer d’un Ă©tat A, Ă  un Ă©tat B, tout en restant fluide.

Etat A : Un carré rouge. Etat B : Un carré vert tourné de 45 degré. Entre ces deux états, plusieurs carrés représentent les états transitoires (couleur qui change petit à petit + carré qui tourne petit à petit).

Traditionnellement, ce qu’on va utiliser pour calculer la transition entre les deux Ă©tats est le temps (t) :

  • Si t = 0, on est au tout dĂ©but de l’animation, alors carrĂ© = rouge & angle = 0
  • Si t = 1, on est Ă  la fin de l’animation, alors carrĂ© = vert & angle = 45°

Et au milieu de tout ça on va faire des interpolations pour calculer les étapes intermédiaires :

  • Si t = 0.25, on a un peu avancĂ© dans l’animation, mais pas beaucoup, donc carrĂ© = plutĂŽt rouge, et angle = ~11°
  • Si t = 0.5, on est au milieu de l’animation, donc carrĂ© = moite-moite entre rouge et vert, et angle = 22.5°
  • Si t = 0.75, on plutĂŽt vers la fin, donc carrĂ© = plutĂŽt vert, et angle = ~34°

Et on peut faire ce calcul pour chacune des images de notre animation.

Cependant, une animation n’est pas forcĂ©ment dans le temps. Par exemple sur le schĂ©ma ci dessus, j’ai fait une animation et finalement sa progression c’est de gauche (t = 0) Ă  droite (t = 1).

Dans le cas d’une Scroll Driven Animation, qu’est-ce qui va ĂȘtre utilisĂ© pour t ? Ce sera la quantitĂ© de scroll qui a Ă©tĂ© effectuĂ©e :

  • si on est en haut de la page, alors on sera Ă  l’état A
  • si on est en bas de la page, alors on sera Ă  l’état B
  • et si on est Ă  la moitiĂ© de la page, alors on sera Ă  la moitiĂ© de l’animation

En pratique, avec du code, ça veut dire que vous aurez besoin de définir plusieurs choses en CSS :

  1. Une animation : qui traditionnellement est représentée avec des @keyframes en CSS

    @keyframes rotate-and-color {
    	/* t = 0 si on reprend mon exemple ci-dessus */
    	0% {
    		background: red;
    		transform: rotate(0deg);
    	}
    
    	/* t = 1 */
    	100% {
    		background: green;
    		transform: rotate(45deg);
    	}
    }
    
  2. Les rĂšgles animation, animation-timeline et animation-range sur l’élĂ©ment que vous voulez animer

    .square {
    	animation: rotate-and-color linear;
    	/* Ce n'est pas le temps, mais le scroll qui dicte la progression de l'animation */
    	animation-timeline: scroll();
    	/* Sur quelle distance de scroll doit se faire l'animation */
    	animation-range: normal 150px;
    }
    

    Ici je définis:

    • quelle animation doit ĂȘtre jouĂ©e via animation
    • que je dois utiliser le scroll et non le temps comme rĂ©fĂ©rentiel via animation-timeline (par dĂ©faut ce sera la premiĂšre scrollbar parente qui sera utilisĂ©e - si vous n’en avez pas d’imbriquĂ©es ce sera donc la scroll bar principale)
    • que je ne vais animer que sur les 150 premiers pixels verticaux via animation-range

Ce qui nous donne ceci : (⚠ Chrome only Ă  ce jour)

Quelques liens pour aller plus loin :

Pourquoi n’est-ce pas trĂšs adaptĂ© sur cette dĂ©mo du sticky ?

Nous avons donc vu que l’animation n’était plus basĂ©e sur le temps mais sur le nombre de pixels qui ont Ă©tĂ© scrollĂ©s. Ainsi, si on pousse les tests un peu plus loin ça veut dire que :

  • Si je m’arrĂȘte en milieu de scroll, le site peut paraĂźtre cassĂ©. En tant que dev je comprends ce qu’il se passe (et si j’ai bien Ă©crit mon article, vous aussi :p) donc ça ne me choque pas outre mesure. Mais qu’en pensera une personne non dev ? Je ne sais pas, mais je prĂ©sume que ça ne laissera pas la meilleure impression.

    Screenshot aprÚs avoir scrollé quelques pixels : le pseudo est à cheval entre l'image foncée de la banniÚre et le fond clair de la page. Il est aussi écrit en blanc en anticipation de son passage sur la banniÚre, mais cela le rend illisible.
  • Si je scroll suffisamment vite pour rapidement dĂ©passer mon animation-range, l’animation n’apparaĂźt quasiment pas. On passe du dĂ©but Ă  la fin trop rapidement pour voir l’animation se dĂ©rouler.

  • Si vous allez sur le CodePen de Jhey, vous verrez qu’il y a besoin de calculer beaucoup de choses : chaque Ă©lĂ©ment animĂ© (l’avatar, la banniĂšre de couverture, le texte, etc.) a sa propre animation-range pour que l’effet soit le plus chouette possible. Heureusement, Ă©tant donnĂ© que les Custom Properties (ou CSS Variables) sont disponibles dans les navigateurs, ce n’est pas une collection de Magic Numbers. Mais ça demande quand mĂȘme beaucoup de concentration Ă  mon petit cerveau et je ne suis pas sĂ»r d’ĂȘtre capable de relire ce genre de code 6 mois plus tard.

Nous allons donc voir ensemble comment j’ai codĂ© le mĂȘme concept mais en utilisant des techniques diffĂ©rentes.

Re-codons ce sticky header sans Scroll Driven Animation

Ce n’est donc pas l’outil idĂ©al pour mettre en place cette animation. Pourtant, on a trĂšs envie d’avoir le mĂȘme effet. Parce que c’est stylĂ©. Donc comment peut-on faire ?

Pour cela, codons ensemble cette dĂ©mo. Vous gagnez en prime cette magnifique grimace quand ma tĂȘte passe en sticky.

Site d'exemple du sticky header

💡 Avant de commencer, je tiens Ă  vous signaler que pour que l’article reste le plus digeste possible, je vais simplifier certains Ă©lĂ©ments. J’essayerai de mettre des laĂŻus sur les parties difficiles qui me paraissent importantes Ă  Ă©clairer, mais il sera difficile d’ĂȘtre exhaustif. Donc si vous voulez une solution complĂšte, n’hĂ©sitez pas Ă  vous rĂ©fĂ©rer directement au code source de la dĂ©mo ou Ă  tout simplement me contacter sur les rĂ©seaux sociaux si vous avez une question en particulier.

1. Mise en place de l’affichage initial et Ă©tat final

Comme d’habitude quand on fait du web, avant de parler style, animation ou quoique ce soit, commençons par nous mettre d’accord sur la structure HTML que nous allons utiliser. Il y a notamment 4 parties qui seront importantes :

  • la banniĂšre de couverture
  • l’avatar
  • le titre (et sous-titre)
  • le bouton de contact
<header class="main-header">
	<img class="cover" src="./cover.jpg" alt="Une magnifique étendue d'herbe" />
	<div class="author">
		<img
			src="./cover.jpg"
			alt="Julien Pradet, son casque sur les oreilles (parce qu'il a oublié de l'enlever pour la photo), sourire aux lÚvres et pas vraiment sérieux."
			class="author__avatar"
		/>
		<div class="author__title">
			<h1 class="author__name">Enchanté, Julien Pradet</h1>
			<p class="author__description">Un dev' top moumoute</p>
		</div>
		<a class="author__contact" href="https://www.julienpradet.fr/">Me contacter</a>
	</div>
</header>

Sur cette base nous allons maintenant pouvoir commencer Ă  appliquer des styles. Il est encore trop tĂŽt pour parler animation, mais un peu plus haut nous avons vu qu’il fallait penser Ă  2 Ă©tats : l’état initial et l’état final. Dans notre cas, nous allons donc devoir Ă©crire le CSS :

  • quand le header n’est pas sticky et qu’on peut se contenter d’afficher l’avatar, puis en dessous, le titre de la page
  • quand le header est sticky et donc que l’avatar rĂ©trĂ©cit et le titre de la page passe sur la droite

Généralement, dans ce genre de situations, je sors mon outil de layout préféré : CSS Grid.

Schema qui montre que pour l'état initial, on a besoin de 3 colonnes, 2 lignes. Et pour l'état sticky, on a besoin d'une seule ligne et 3 colonnes

Ainsi, nous pouvons garder exactement la mĂȘme structure HTML pour des affichages rĂ©ellement diffĂ©rents. Le tout est de jouer avec les lignes et les colonnes pour aboutir Ă  l’alignement rĂȘvĂ©. D’ailleurs, si jamais vous voulez aller plus loin, la semaine derniĂšre je vous parlais de Comment display: contents peut vous aider Ă  conserver la mĂȘme structure HTML avec CSS Grid.

Voici par exemple Ă  quoi pourrait ressembler le CSS pour l’état initial:

.author {
	display: grid;
	grid-template-areas:
		'Avatar . .'
		'Title Title Contact';
	grid-template-columns: 8rem 1fr auto;
	gap: 2rem;
}

.author__avatar {
	grid-area: Avatar;
}

.author__title {
	grid-area: Title;
}

.author__contact {
	grid-area: Contact;
}

Ainsi, la seule chose qu’on a besoin de changer pour l’état final serait :

  • d’ajouter une classe .author--sticky Ă  la div.author
  • ajouter ces quelques lignes de CSS pour prĂ©ciser que l’organisation est diffĂ©rente en mode sticky
    .author--sticky {
    	grid-template-areas: 'Avatar Title Contact';
    	grid-template-columns: 3rem 1fr auto;
    	gap: 1rem;
    }
    

Comme vous pouvez le constater, pour l’instant, pas de calculs particuliers. On code uniquement notre CSS afin qu’il soit capable d’afficher indĂ©pendamment l’état normal ou l’état sticky. La seule contrainte est de minimiser la modification du HTML en ne s’autorisant qu’à changer des classes (en l’occurrence .author--stick).

💡 Quelques petites subtilitĂ©s que je n’ai pas mentionnĂ© qui viendront plus tard:

  • pas de position: sticky ni de position: fixed ?
  • Ă  quel moment l’avatar dĂ©passe-t-il sur la banniĂšre de couverture ?

💡 Une autre question que vous pouvez vous poser : Comment gĂ©rer une max-width tout en s’assurant que le contenu est centré ?

En effet, on ne peut pas rĂ©ellement se contenter d’un max-width: 60rem; margin: 0 auto; parce qu’on veut que le fond dĂ©passe Ă  gauche et Ă  droite. Il a plusieurs solutions Ă  cela, mais celle que j’ai tendance Ă  privilĂ©gier en ce moment est d’ajouter des pseudo Ă©lĂ©ments Ă  la grille qui seront responsables de gĂ©rer l’espace Ă  gauche et Ă  droite. Sur le principe ça ressemble Ă  quelque chose de ce style:

.author {
	--container-padding: 0.5rem;
	--max-container-width: 60rem;
	--gutter-width: max(
		var(--container-padding),
		calc((100% - var(--max-container-width)) / 2 + var(--container-padding))
	);

	grid-template-areas:
		'GutterLeft Avatar . . GutterRight'
		'GutterLeft Title Title Contact GutterRight';
	grid-template-columns: var(--gutter-width) 8rem 1fr auto var(--gutter-width);
}

.author::before {
	content: '';
	grid-area: GutterLeft;
}

.author::after {
	content: '';
	grid-area: GutterLeft;
}

Je passe un peu rapidement sur cette partie. Si ce n’est pas suffisamment clair, n’hĂ©sitez pas Ă  m’envoyer un message. J’en ferai un article.

En tout cas, dans l’idĂ©e, ça nous a amenĂ© Ă  ce rĂ©sultat :

Affichage des 2 modes, normal avec le header qui prend beaucoup de place, puis sticky oĂč le header est beaucoup plus petit

2. Mise en place du JavaScript pour passer d’un Ă©tat Ă  l’autre

Passons maintenant au code JavaScript qui sera responsable de passer d’un Ă©tat Ă  l’autre. Le premier rĂ©flexe vu qu’on parle de changement qui doit apparaĂźtre au scroll est d’ajouter un listener sur l’évĂ©nement scroll. Cependant cette mĂ©thode n’est pas pratique Ă  utiliser et peut rapidement aboutir Ă  des problĂšmes de performance. Essayez donc de l’éviter au maximum.

Nous allons donc passer par un IntersectionObserver. C’est une API disponible dans les navigateurs qui permet de dĂ©clencher un Ă©vĂ©nement quand la visibilitĂ© d’un Ă©lĂ©ment Ă  changer.

const observer = new IntersectionObserver((entries) => {
	entries.forEach((entry) => {
		console.log(entry.isIntersecting);
		// Affiche true si l'élément observé est visible
		// Affiche false sinon
	});
});

observer.observe(element);

Dans notre cas, quel serait l’élĂ©ment qu’on voudrait observer ? Quand est-ce que la visibilitĂ© (ou l’invisibilitĂ©) d’un Ă©lĂ©ment peut nous indiquer qu’on veut dĂ©clencher le mode sticky ? GĂ©nĂ©ralement il s’agit de l’élĂ©ment qu’on va manipuler. Mais ici le principe est justement que notre .author ne doit jamais disparaĂźtre. Nous allons donc plutĂŽt observer l’image de la cover.

De plus, nous allons devoir configurer l’IntersectionObserver pour qu’il se dĂ©clenche non pas quand le dernier pixel de la cover n’est plus visible, mais un peu plus tĂŽt pour que l’avatar soit encore majoritairement visible avant de passer en mode sticky. On va donc essayer de viser la ligne rouge ci-dessous qui vient couper l’avatar vers 1/4 de sa hauteur.

Représentation schématique du header en mode non sticky, avec une ligne rouge pour indiquer à quel niveau de scroll on veut déclencher le mode sticky

Pour ce faire, nous allons passer des options supplĂ©mentaires Ă  l’IntersectionObserver et plus prĂ©cisĂ©ment, nous allons devoir configurer la rootMargin : c’est elle qui nous permettra de dire Ă  quelle distance de la fin de l’image on doit dĂ©clencher l’observer. Nous allons donc lui mettre quelques pixels nĂ©gatifs.

const author = document.querySelector('.author');
const cover = document.querySelector('.cover');

const observer = new IntersectionObserver(
	(entries) => {
		entries.forEach((entry) => {
			// Si entry.isIntersecting, ça veut dire qu'une grande partie
			// de la cover est visible => on enlĂšve la classe `author--sticky`
			// Si au contraire !entry.isIntersecting, on a dépassé la ligne
			// rouge => on ajoute la classe `author--sticky`
			author.classList.toggle('author--sticky', !entry.isIntersecting);
		});
	},
	{
		// DĂšs qu'il y a ne serait-ce qu'un pixel au dessus de la ligne rouge
		// => donc threshold = 1
		threshold: 0,
		// On met la hauteur en pixel entre la fin de l'image et la ligne rouge
		// C'est négatif parce qu'on entre à l'intérieur de l'image. Si le nombre
		// était positif, alors la ligne rouge se retrouverait sous l'image
		rootMargin: '-32px 0px'
	}
);

observer.observe(cover);

Et lĂ , magie, nous avons le passage du mode normal au mode sticky.

Pas tout Ă  fait cependant parce que mĂȘme si l’organisation de l’affichage a changĂ©, ce n’est pas “sticky” au sens oĂč si on scroll en bas de la page, le header n’est plus visible.

3. Faire fonctionner le sticky

Dans la plupart des cas sticky vous allez pouvoir vous contenter d’utiliser position: sticky.

Cependant il y a plusieurs problùmes qui font que ce n’est pas possible dans notre cas:

  • position: sticky est relatif Ă  la balise parente.

    Étant donnĂ© que notre div.author est Ă  l’intĂ©rieur d’une balise header, alors sa zone de stickiness s’arrĂȘtera dĂšs qu’on aura scrollĂ© au delĂ  du header. Ce n’est pas un problĂšme totalement incontournable parce que nous pourrions travailler le HTML pour faire en sorte que notre div.author soit un enfant direct du body. Mais ça demanderait quelques ajustements sĂ©mantiques.

  • Le navigateur n’arrivera pas Ă  gĂ©rer un changement de hauteur en fonction de si on est sticky ou non.

    Dans le cadre de ma dĂ©mo, il n’y a pas que la grille qui change. Afin de prendre le minimum de place, je change aussi le padding, la font-size et je cache le lien vers les rĂ©seaux sociaux. La consĂ©quence c’est que la height de ma div.author va changer selon si elle est en author--sticky ou non. Et ça, le navigateur ne va pas du tout apprĂ©cier. Du tout. En gros, Ă  peine vous aurez atteint le mode sticky, que le navigateur va vous en sortir instantanĂ©ment parce que la hauteur totale de votre page aura changĂ© et donc vous fera ressortir de l’IntersectionObserver. Vous pouvez vous rĂ©fĂ©rer Ă  ce CodePen pour constater le problĂšme.

Pour ces raisons, nous n’allons pas utiliser position: sticky, mais position: fixed.

.author--sticky {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
}

Ainsi, quand on passe en mode sticky, la div.author viendra effectivement coller le haut de l’écran. Mais si vous essayez cette solution vous constaterez que la hauteur totale du scroll de la page change au moment de l’ajout de author--sticky. Cela vient du fait que la hauteur de div.author passe en fixed et donc n’est plus comptabilisĂ©e dans le flow du document.

L’astuce que j’ai appliquĂ©e pour rĂ©gler ce problĂšme est de dĂ©finir une min-height sur .main-header. Et pour Ă©viter des tailles en dur, c’est au niveau du JS que je vais gĂ©rer cela :

const mainHeader = document.querySelector('.main-header');
mainHeader.style.minHeight = `${mainHeader.clientHeight}px`;

// ... puis le reste du code JS qu'on avait déjà écrit
// avec l'IntersectionObserver & co

Ainsi, au moment de l’initialisation de la page, je prĂ©cise que le header devra toujours faire au moins sa taille courante. De fait, quand div.author passera en position: fixed, .main-header conservera la bonne taille.

💡 Quand on fait des calculs de taille au chargement de la page, il faut penser Ă  gĂ©rer des redimensionnement de navigateur. En effet, un cas courant est le fait de tourner votre tĂ©lĂ©phone pour mieux voir le contenu. Or, en position portrait et en position paysage, la hauteur du main-header ne sera pas du tout la mĂȘme. Souvent cette partie est assez complexe et ce n’est pas le cƓur du sujet que je veux aborder dans ce tutoriel. Si toutefois vous voulez voir comment j’ai gĂ©rĂ© ça dans la dĂ©mo, je vous invite Ă  jeter un coup d’Ɠil au code source.

4. Animons la transition vers le mode sticky

A ce stade, nous avons donc un header qui peut ĂȘtre en mode normal ou en mode sticky en fonction du niveau de scroll. Mais ce que je disais en introduction, c’est que c’est l’animation qui amĂ©liore la perception de qualitĂ© de notre page. Donc voyons comment l’implĂ©menter.

Pour cela, nous allons utiliser les animations FLIP. Pour celles et ceux qui me suivent depuis longtemps, ça vous parlera peut-ĂȘtre parce que j’ai dĂ©jĂ  Ă©crit un article Ă  ce sujet il y a plus de 5 ans.

En quelques mots, le but de ces animations est de calculer la diffĂ©rence entre l’état de dĂ©part et l’état d’arrivĂ©e pour ensuite ĂȘtre capable de faire l’animation uniquement en utilisant les propriĂ©tĂ©s CSS transform et opacity qui sont le seul moyen d’avoir des animations performantes sur le web.

Plus précisément cela se fait en 4 étapes, une par lettre :

  • First : on enregistre la position de dĂ©part de l’élĂ©ment (ex : {top: 100, left: 50})
  • Last : on place l’élĂ©ment dans sa position finale et on enregistre sa position (ex : {top: 130, left: 50})
  • Invert : on calcule la diffĂ©rence entre les deux positions (ex : {top: -30, left: 0}) et on l’applique Ă  l’élĂ©ment. Ainsi, quand on applique cette transformation Ă  l’élĂ©ment avec la propriĂ©tĂ© CSS transform, on aura visuellement l’impression qu’il est Ă  sa position initiale.
  • Play : puis on lance l’animation. Cela peut se faire en dĂ©finissant la propriĂ©tĂ© CSS transition: transform 0.4s ease-in-out, puis en retirant la propriĂ©tĂ© transform.

Ce paradigme est assez rĂ©pandu dans le domaine du web parce que c’est la mĂ©thode magique qui permet de faire des animations performantes. Quelques outils qui peuvent vous aider Ă  mettre ça en place:

Dans le cadre de ce tutoriel, je suis en Vanilla alors j’ai rĂ©utilisĂ© ce que j’avais dĂ©jĂ  commencĂ© Ă  Ă©crire dans mon prĂ©cĂ©dent tutoriel. J’ai cependant amĂ©liorĂ© la librairie pour que l’étape Play soit faite en CSS plutĂŽt que de la gĂ©rer manuellement en JS. Les animations sont donc encore plus performantes qu’avant 🚀

J’ai par ailleurs ajoutĂ© un petit wrapper afin de simplifier son utilisation. De fait, on va pouvoir mettre en place l’animation en 2 Ă©tapes :

  • Ajouter sur chacun des Ă©lĂ©ments que je veux animer une classe permettant de les identifier (ici js-animate) :

    • l’avatar
    • le titre
    • le contact
  • Dans le JS, entourer le changement de class de ma fonction animate

    +import { animate } from './animate.js';
    
    const observer = new IntersectionObserver(
    	(entries) => {
    		entries.forEach((entry) => {
    +			const elementsToAnimate = author.querySelectorAll('.js-animate');
    +			animate(elementsToAnimate, () => {
    				author.classList.toggle('author--sticky', !entry.isIntersecting);
    +			});
    		});
    	}
    	/* ... */
    );
    

    💡 Peut ĂȘtre remarquerez vous que l’API ressemble beaucoup Ă  document.startViewTransition. C’est volontaire, mais je ne vais pas l’utiliser dans ce tutoriel par manque de support navigateur. Je ferai cela dit un article dans les prochaines semaines pour vous en parler en dĂ©tail. J’ai notamment des problĂ©matiques de performance Ă  vous partager Ă  ce sujet.

Si vous en ĂȘtes arrivĂ©s Ă  ce stade, le plus gros du travail est fait.

Cela dit, il y a quelques difficultĂ©s que j’ai passĂ© sous silence.

Le titre n’est pas toujours sur une seule ligne.

En effet sur certaines rĂ©solutions, le titre va ĂȘtre sur 2 lignes en mode normal et 1 seule en mode sticky.

Sur le screen de gauche, on voit que le titre "Enchanté Julien Pradet" est sur 2 lignes, mais sur le screen de droite, en mode sticky, cela passe sur une seule ligne

L’astuce est alors de transformer le HTML pour dĂ©couper chaque mot dans sa propre balise afin qu’elle ait sa propre transition :

-<h1 class="author__name js-animate">
-    Enchanté, Julien&nbsp;Pradet
+<h1 class="author__name">
+    <span class="js-animate">Enchanté,</span>
+    <span class="js-animate">Julien&nbsp;Pradet</span>
 </h1>

💡 A noter toutefois que ça ne marchera pas sur des Ă©lĂ©ments inline (le comportement par dĂ©faut d’un span). Il faudra donc penser Ă  ajouter en CSS la propriĂ©tĂ© display: inline-block.

Par ailleurs, si votre texte fait 5 paragraphes, ça va commencer à devenir assez lourd de calculer chaque animation FLIP. Donc à appliquer avec parcimonie.

Si je n’anime que les Ă©lĂ©ments internes, ça veut dire que le fond ne va pas s’animer.

Comment faire pour que pendant l’animation la zone couleur crĂšme soit elle aussi animĂ©e ?

L’astuce est de considĂ©rer le fond comme un enfant supplĂ©mentaire. Nous allons donc ajouter une nouvelle div vide Ă  l’intĂ©rieur de div.author:

<div class="author">
	<div class="author__background js-animate"></div>
	<!-- le reste du HTML de div.author -->
</div>

Il faut bien penser Ă  lui ajouter la classe js-animate vu qu’elle fait partie des Ă©lĂ©ments Ă  animer.

Par ailleurs, Ă©tant donnĂ© qu’elle est vide, il va falloir trouver un moyen d’adapter sa taille au contenu. Pas question d’utiliser des hauteurs de pixel en dur. Une mĂ©thode qui marche en gĂ©nĂ©ral assez bien est plutĂŽt d’utiliser un position: absolute :

.author {
	position: relative;
	/* C'est en définissant le z-index ici que je m'assure
    que les z-index des enfants n'impacteront pas l'extérieur */
	z-index: 1;
	/* Au passage, c'est grùce à ce margin-top négatif
    que l'avatar va venir dépasser par dessus la cover */
	margin-top: -4rem;
}

.author__background {
	position: absolute;
	inset: 0;
	top: 4rem;
	background: var(--color-creme);
	z-index: -1;
}

.author--sticky {
	position: fixed;
	/* Il faut bien penser Ă  remettre Ă  0 le margin-top
    en sticky vu que l'avatar ne déborde plus */
	margin-top: 0;
}

.author--sticky .author__background {
	top: 0;
	/* Et vous pouvez en profiter pour changer d'autres choses */
	opacity: 0.5;
}

Enfin, il est important de ne jamais animer si prefers-reduced-motion est activé.

En effet, certains contenus animĂ©s peuvent donner des nausĂ©es aux personnes qui les regardent - mĂȘme si l’animation vous paraĂźt subtile.

Il existe une media query pour ça en CSS : @media (prefers-reduced-motion: reduce). En JS, on peut donc la reproduire via window.matchMedia('(prefers-reduced-motion: reduce)').matches. Si vous ĂȘtes passĂ© directement par ma librairie animate mentionnĂ©e plus haut, sachez que c’est dĂ©jĂ  gĂ©rĂ© Ă  l’intĂ©rieur. Mais si vous utilisez un autre systĂšme, pensez Ă  bien vĂ©rifier qu’en activant l’option, vos animations ne sont plus dĂ©clenchĂ©es.

Pour le tester dans les DevTools de Chrome, vous pouvez aller dans ⋼ > More tools > Rendering, puis activer l’option prefers-reduced-motion: reduce:

Screenshot de l'option "Emulate CSS meda feature prefers-reduced-motion" dans les DevTools de Chrome

5. Animons l’avatar

Enfin, cerise sur le gateau, ce qui rend la dĂ©mo de Jhey aussi chouette est le fait que son avatar s’anime quand il passe en sticky. Au dĂ©but il regarde vers la droite, et une fois passĂ© en sticky, il regarde vers le bas.

La technique utilisĂ©e pour ceci est d’utiliser un sprite. Le principe est d’avoir une seule image qui contient toutes les Ă©tapes de votre animation.

Screenshot de l'option "Emulate CSS meda feature prefers-reduced-motion" dans les DevTools de Chrome

On l’occurrence j’ai pris une vidĂ©o de moi qui fait l’andouille, puis j’ai rĂ©cupĂ©rĂ© les images intermĂ©diaires via ce site. Puis en CLI, j’ai pu crĂ©er le montage ci-dessus:

montage images/*.jpg -mode concatenate -tile x1 sprite.jpg

Ensuite, en CSS l’astuce va ĂȘtre de jouer avec object-position. En effet, quand on affiche une image par dĂ©faut, elle va s’afficher en suivant le ratio de celle-ci. Cependant, notre avatar, on veut qu’il soit carrĂ©. Donc on va forcer en CSS une width et une height dĂ©finie et ajouter object-position: cover pour que toute la hauteur de l’image soit affichĂ©e.

.author__avatar {
	width: 8rem;
	height: 8rem;
	object-fit: cover;
}
Le mĂȘme sprite mais affichĂ© avec les options CSS sus-mentionnĂ©es. Un carrĂ© est affichĂ© avec sur la moitiĂ© gauche la partie droite de mon visage et sur la moitiĂ© droite, la partie gauche de mon visage.

Mais comme vous pouvez le constater, on se retrouve au milieu de deux images. C’est donc l’object-position va nous permettre de dire : est-ce qu’on veut afficher le dĂ©but ou la fin ? En le mettant Ă  0, on va donc afficher le dĂ©but.

.author__avatar {
	width: 8rem;
	height: 8rem;
	object-fit: cover;
+	object-position: 0 0;
}
Le mĂȘme sprite mais cette fois on ne voit que la premiĂšre image, avec mon visage qui regarde la camĂ©ra.

Il ne reste plus qu’à crĂ©er une animation en utilisant @keyframes en CSS. L’astuce ici va ĂȘtre d’utiliser steps(19) comme timing-function. En effet, mĂȘme si on est plutĂŽt habituĂ© Ă  l’utilisation de linear, ease-in-out voire des cubic-bezier(), il est aussi possible de dĂ©finir un nombre fini de frames dans votre transition. C’est donc ce steps(19) qui va nous permettre de faire en sorte que ce soit toujours une tĂȘte entiĂšre qui soit visible. N’hĂ©sitez pas Ă  faire varier le nombre en fonction du nombre d’images prĂ©sentes dans votre sprite.

.author__avatar {
	animation: 0.4s steps(19) 0s 1 normal forwards look-down;
}

@keyframes look-down {
	from {
		object-position: 0 0;
	}
	to {
		object-position: 100% 0;
	}
}

Vous pouvez tester sur l’image ci-dessous en cliquant dessus:

Le mĂȘme sprite mais cette fois on ne voit que la premiĂšre image, avec mon visage qui regarde la camĂ©ra.

Clique sur mon nez !

💡 Pourquoi ne pas avoir utilisĂ© transition: object-position 0.4s steps(19) plutĂŽt qu’animation ?

En effet, il est tout Ă  fait possible d’utiliser la transition-timing-function: steps(x) pour une transition. Le souci est que si votre transition est interrompue en milieu d’animation, alors elle continuera d’utiliser 19 steps pour revenir Ă  sa position initiale. Dans les faits, vous vous retrouverez donc avec une animation buggĂ©e. Cela n’arrive pas avec des @keyframes et une animation parce que cela dĂ©marrera toujours de l’état initial vers l’état final. L’animation ne pourra pas commencer dans un Ă©tat intermĂ©diaire.

Nous sommes maintenant capable d’animer notre avatar. Comment l’associer Ă  notre animation du sticky ? Étant donnĂ© que l’animation doit ĂȘtre jouĂ©e qu’une seule fois Ă  un moment prĂ©cis (quand le sticky se met en place, ou quand on l’enlĂšve), on va le faire en deux temps :

  1. Ajouter une classe CSS .author--animating qui, quand elle est prĂ©sente, dĂ©clenche l’animation :
.author__avatar {
	/* On affiche la premiĂšre frame */
	object-position: 0 0;
}

.author--sticky .author__avatar {
	/* En sticky, c'est plutĂŽt la derniĂšre frame */
	object-position: 0 0;
}

/* On ajoute la media query pour les questions d'accessibilité
évoquées plus haut */
@media (prefers-reduced-motion: no-preference) {
	/* Si pas author--sticky, alors on est en train de
    revenir au mode normal donc on `reverse` l'animation */
	.author--animating .author__avatar {
		animation: 0.4s steps(19) 0s 1 reverse forwards look-down;
	}

	/* Si au contraire, on est en train de passer
    en mode sticky, l'animation doit se dérouler dans
    le sens normal */
	.author--sticky.author--animating .author__avatar {
		animation-direction: normal;
	}
}
  1. Et dans le JS, dans l’IntersectionObserver, on ajoute cette classe author--animating :
const observer = new IntersectionObserver(
	(entries) => {
		entries.forEach((entry) => {
			const elementsToAnimate = author.querySelectorAll('.js-animate');
			animate(elementsToAnimate, () => {
+				author.classList.add('author--animating');
				author.classList.toggle('author--sticky', !entry.isIntersecting);
+			}).then(() => {
+				// Une fois que l'animation est terminée on retire la class
+				// pour ĂȘtre sĂ»r que l'animation se rejouera au prochain coup
+				author.classList.remove('author--animating');
			});
		});
	}
	/* ... */
);

Et voilĂ  🎉 Notre avatar est animé !

Lazyloader le Sprite pour garder un premier affichage rapide

J’ai une derniĂšre remarque cela dit : nĂ©cessairement le sprite qui contient toutes les frames de l’animation de ma tĂȘte est assez lourd. Le problĂšme de cette technique est donc que ça va ralentir l’affichage initial de la page par rapport Ă  un avatar normal. On parle d’un ratio x15 sur le poids de l’image đŸ˜± Histoire de garder des performances convenables, j’ai donc optĂ© pour une sorte de lazyloading :

<img
	src="./avatar.jpg"
	data-sprite-src="./sprite_avatar.jpg"
	width="232"
	height="232"
	class="author__avatar js-animate js-avatar"
/>

Initialement, le navigateur va charger mon avatar simple qui contient uniquement la premiĂšre frame de ma tĂȘte. Puis, en JavaScript je vais venir modifier l’URL de l’image pour ĂȘtre en mesure de faire l’animation. Ainsi, la grosse image est chargĂ©e plus tard et elle n’est chargĂ©e que si le JavaScript a rĂ©ussi Ă  s’initialiser.

Pour appliquer cette mĂ©thode en JS j’ai ajoutĂ© ce petit script:

const sprite = document.createElement('img');
sprite.src = avatar.dataset.spriteSrc;
sprite.addEventListener('load', () => {
	avatar.src = sprite.src;
});

L’astuce est de faire en sorte d’attendre que l’image ait fini de charger avant de changer l’attribut src de mon avatar. Si je n’avais pas fait ça, j’aurais pu avoir un flash blanc sur mon image.

Conclusion

Nous voilĂ  arrivĂ© au bout. đŸ„ł Si vous ressentez le besoin de creuser un peu plus, n’oubliez pas que le code source de la dĂ©mo est disponible sur mon GitHub.

Est-ce que malgrĂ© tout on a bien fait notre travail ? Pour ça revenons Ă  la liste des problĂšmes qu’on avait repĂ©rĂ© sur les Scroll Driven Animations :

  • on ne reste plus bloquĂ© dans un Ă©tat intermĂ©diaire pendant le scroll
  • quelle que soit la vitesse de scroll, l’animation est lisible : tout le monde verra le header s’animer pour passer d’une position normale Ă  sticky
  • il n’y a plus de calcul savant pour calculer des positions et dimensions. Tout est gĂ©rĂ© avec CSS Grid puis calculĂ© automatiquement en JS. La seule dimension manuelle est celle du rootMargin de l’IntersectionObserver

A priori, la mission est donc plutît remplie. D’autant plus que cette technique permet aussi :

  • de changer la taille du texte sans pour autant souffrir de problĂšmes de performance
  • d’animer des changements plus complexes (ex : le texte qui passe de 2 lignes Ă  une seule en mobile)

Par contre, elle crĂ©e aussi de nouveaux problĂšmes. Notamment, si on scroll peu, on se retrouve avec un grand espace blanc. Et si vraiment on joue avec la limite, on peut mĂȘme se retrouver avec le sticky qui vient par dessus la cover.

En étant au tout début du sticky, on voit une petite partie verte sous le sticky. Et il y a facilement 200px de blanc ensuite sous le sticky.

Personnellement je pense que le compromis est acceptable comparĂ© Ă  l’état cassĂ© de la Scroll Driven Animation. Par contre c’est ce qui me fait dire que si je devais intĂ©grer ce design sur un site en production, je continuerais Ă  faire quelques adaptations. Notamment, j’essayerais de travailler pour que la diffĂ©rence de hauteur entre sticky et non sticky soit moins flagrante afin de diminuer la hauteur de l’espace blanc que cela crĂ©e.

En tout cas c’était une chouette dĂ©mo Ă  faire et ça m’a remotivĂ© Ă  faire des animations partout. Cela dit, je veux me pencher sur le sujet de l’API View Transitions parce qu’en se jetant corps et Ăąmes dedans, j’ai peur que ça ouvre des nouvelles problĂ©matiques de performance. Je publierai un article Ă  ce sujet courant septembre je pense.

Si vous voulez continuer de suivre mes tutoriels, n’hĂ©sitez pas Ă  me suivre sur les rĂ©seaux sociaux (Mastodon, LinkedIn ou Twitter, voire mĂȘme RSS). J’essaye de publier un article par semaine autour de la performance web et du dĂ©veloppement front-end dans son ensemble pour partager mes dĂ©couvertes et ma façon de travailler.

Si lire des articles vous paraĂźt trop long et que vous avez des besoins plus urgents Ă  adresser, sachez que je suis disponible en freelance pour vous aider Ă  monter en compĂ©tence, rĂ©gler vos soucis de performance ou ajouter un petit plus Ă  votre site web. Alors n’hĂ©sitez pas Ă  me contacter !

Des bisous et à la semaine prochaine 😘


Enfin, si cet article vous a ouvert l’appetit sur les Scroll Driven Animations, voici quelques ressources qui mĂ©ritent votre attention :

Ce sont ces ressources qui me font dire qu’il y a bel et bien un usage pour cette nouvelle API. Cependant, je restreindrais son utilisation à certains cas :

Vous en voyez d’autres ? đŸ€”


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 :)