J’ai bossé récemment sur plusieurs sites web, et la requête d’un menu sticky était quasiment systématique. Parfois elle était justifiée, parfois je me rapprochais du contre-exemple ergonomique stéréotypé. (Dois-je utiliser un sticky menu ?) Mais quand même ! Je vous propose de voir ensemble comment on peut faire ça.

Concept du menu sticky

J’aime bien partir d’une idée et poser quelques éléments par écrit pour ne pas partir tête baissée dans le code. Dans un premier temps, nous sommes tous d’accord sur la définition du menu sticky ? C’est ce truc qui colle au haut (souvent) de la page lorsque l’on scroll vers le bas.

Je veux bien diviser le fond (HTML), la forme (CSS) et les interactions (JS), et dans cet ordre là précisément. Je vais donc m’interdire toute forme d’animation ou de positionnement en JS, je vais utiliser CSS pour cela.

Le comportement attendu est celui-ci : J’ai un en-tête avec un logo et un menu qui sont « classiquement » positionnés. Au delà d’un certain seuil de scroll (descente) dans la page, je veux ré-afficher le menu à l’utilisateur pour lui permettre un accès plus rapide. Dans un premier temps, la valeur de scroll sera arbitraire (définie manuellement par nos soins), puis dans un second temps nous ferons en sorte que le menu n’apparaisse que lorsque le scroll aura passé un certain élément dans la page. Enfin, nous verrons également une variante où le menu ne réapparait que si l’utilisateur remonte dans la page.

Nous aurons à la fin quelque chose dans ce goût :

Démonstration

La structure de notre menu sticky

Je vous invite à utiliser cette structure de menu, et éventuellement à copier/coller le Lorem Ipsum de ma page de démonstration pour avoir du contenu et pouvoir scroller dans votre page.

<header id="header" role="banner" class="main-header">
	<div class="header-inner">
 
		<div class="header-logo">
			<img src="logo.png" alt="Creative Juiz" width="150" height="45">
		</div>
 
		<nav class="header-nav">
			<ul>
				<li><a href="#">Home</a></li>
				<li><a href="#">Our projects</a></li>
				<li><a href="#">About us</a></li>
				<li><a href="#">Contact</a></li>
			</ul>
		</nav>
 
	</div>
</header>

J’utilise l’élément <header> avec le role banner pour définir l’en-tête de ma page. Le reste est assez classique. Il est important de noter que l’identifiant (header) va me servir ici en JS afin d’accéder rapidement à l’élément.

Les styles de notre sticky menu

Ces styles vont être assez sommaires et ne traitent pas le cas du responsive. Dans l’idéal, l’aspect sticky ne devrait être « activé » que sur des écrans assez larges et hauts pour éviter de gêner la visibilité du contenu.

Sticky Header

Commençons par faire un mini reset de certains styles :

/* micro reset */
* {
	box-sizing: border-box;
}
html, body, ul {
	padding: 0; margin: 0;
}

Puis à définir nos styles de base. Je vais utiliser les couleurs de mon blog, faites comme vous le souhaitez, bien entendu.

/* Quelques styles de base */
body {
	font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
	line-height: 1.75;
	color: #F1EEDD;
	background: #B13C2E;
}
.main-header {
	color: #B13C2E;
	background: #FFF;
}

Vous pouvez actualiser votre page dans votre navigateur si vous suivez le tutoriel au fur et à mesure, comme cela vous verrez précisément les modifications que nous faisons.
Nous allons maintenant placer nos éléments sur la même ligne. Au lieu d’utiliser le positionnement en flottant, j’ai décidé d’utiliser table layout pour cette fois. Il serait également possible d’utiliser flexbox, mais il faut bien faire un choix 🙂

/* Les éléments sont placés l'un à côté de l'autre */
.header-inner {
	display: table;
	width: 100%;
	max-width: 1100px;
	margin: 0 auto; /* on centre l'élément */
	padding: 20px 25px; /* on ventile un peu */
}
.header-inner > * {
	display: table-cell;
	vertical-align: middle;
}

En utilisant ce positionnement en tableau sans utiliser table-layout: fixed; sur le parent, les cellules vont se dimensionner suivant le contenu de chacune. Profitons-en !

La propriété table-layout: fixed; permet de définir précisément les dimensions de chaque cellule. Si vous l’utilisez sans précision des valeurs de width sur les enfants, ces derniers se répartiront la largeur disponible, ici 50% chacun. Un tableau sans table-layout: fixed; met théoriquement plus de temps à être affiché par le navigateur. (on parle de micro/milli secondes pour effectuer la répartition des dimensions)

Il va maintenant falloir passer notre liste de liens sur une seule ligne et espacer un peu les items les uns des autres. Il faut également que nous alignions le menu à droite. Je vais vous montrer une astuce pour éviter d’utiliser la propriété float.

/* Alignement du menu */
.header-nav {
	text-align: right;
}
/*
   Faire passer le menu en inline (inline-block, inline-table ou inline-flex) pour le rendre sensible à l'alignement à droite. Ses items aussi sont en inline.
*/
.header-nav ul,
.header-nav li {
	display: inline;
	list-style: none;
}
.header-nav a {
	position: relative;
	display: inline-block;
	padding: 8px 20px;
	vertical-align: middle;
	font-weight: 300; /* entre regular et light */
	letter-spacing: 0.025em;
	color: inherit;
	text-decoration: none;
}

Sur les liens, j’ai décidé de faire une petite animation au survol (et focus) : un trait de la taille du mot va venir s’ajouter en remontant vers le mot en fondu. Pour cela je vais utiliser un pseudo-élément :after pour générer un trait.

/* Animation du lien */
.header-nav a:after {
	content: "";
	position: absolute;
	bottom: 0; right: 20px; left: 20px;
	height: 2px;
	background-color: #B13C2E;

	/* Préparation de notre animation */
	opacity: 0;
	transform: translateY(5px);
	transition: all .4s;
}
/* Le trait va remonter et apparaitre */
.header-nav a:hover:after,
.header-nav a:focus:after {
	opacity: .6;
	transform: translateY(0);
}
/* Je vire outline car juste au-dessus je définis un style :focus */
.header-nav a:focus {
	outline: none;
}

Voilà, nous avons globalement un header minimaliste au goût du jour et l’aspect sticky de notre menu va pouvoir être travaillé grâce à du JS. Mais avant cela, petite parenthèses.

Menu sticky : la propriété CSS position: sticky;

La propriété CSS position et sa valeur sticky ont été introduites en CSS Level 3 mais a eu beaucoup de mal à être implémentée dans nos navigateurs, et encore aujourd’hui (à l’heure où j’écris ces lignes) seuls Firefox 32+ (et 41+ pour Android), Safari 6.1+ (préfixe -webkit-, et Safari iOS 6.1+) le supportent.

Chrome a fait une tentative entre les versions 23 et 36 sous la forme d’option d’expérimentation à activer (flag), mais a retiré ce flag en version 37, depuis plus de nouvelle.

Cette valeur de propriété est un mélange de la valeur fixed et de la valeur relative.
En effet, lorsque vous utilisez la valeur fixed, vous sortez l’élément du flux, c’est à dire que l’élément que vous positionnez va voir sa « boîte » sortie du calcul de positionnement général des éléments les uns par rapport aux autres. Autrement dit, vous passez cet élément sur un nouveau plan, il n’est alors plus compté sur son plan d’origine où les autres éléments vous faire leur petite vie sans lui.

Si vous voulez faire un essai dans votre navigateur voici une petite explication (5 min) en vidéo.

Voici le code utilisé :

/* Si seulement ça suffisait */
.main-header {
	position: sticky;
	top: 0;
}

Mais nous n’allons pas l’utiliser pour la suite de ce tutoriel.

Un peu de JavaScript !

Le JavaScript présenté ici est dit « Vanilla », c’est à dire que le code est fonctionnel nativement dans vos navigateurs et n’a pas besoin de jQuery pour fonctionner. Mais il peut quand même fonctionner si vous utilisez une bibliothèque JS.

Ce code est normalement fonctionnel à partir de IE8 (non testé), mais j’ose espérer que plus aucun de mes lecteurs ne supportent ce navigateur. (je dis ça pour votre bien et celui de vos clients)

Pour commencer, dans votre document JS, inscrivez ce polyfill de requestAnimationFrame().

/*
	RequestAnimationFrame Polyfill

	http://paulirish.com/2011/requestanimationframe-for-smart-animating/
	http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating
	by Erik Möller, fixes from Paul Irish and Tino Zijdel

	MIT license
 */ 

(function() {
	var lastTime = 0;
	var vendors = ['ms', 'moz', 'webkit', 'o'];
	for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
		window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame'];
		window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame'];
	}

	if ( ! window.requestAnimationFrame ) {
		window.requestAnimationFrame = function(callback, element) {
			var currTime = new Date().getTime();
			var timeToCall = Math.max(0, 16 - (currTime - lastTime));
			var id = window.setTimeout(function() { callback(currTime + timeToCall); }, 
			timeToCall);
			lastTime = currTime + timeToCall;
			return id;
		};
	}

	if ( ! window.cancelAnimationFrame ) {
		window.cancelAnimationFrame = function(id) {
			clearTimeout(id);
		};
	}
}());

Je ne vais pas détailler ce code, mais pour faire court si votre navigateur ne reconnaît pas les fonctions natives requestAnimationFrame() et cancelAnimationFrame(), nous les créons sur la base d’un setTimeout(). Nous avions déjà vu une méthode similaire sur l’article : Un onresize ou onscroll plus performant en JS.

Commençons par écrire notre code en le protégeant. Ci-dessous, w et d correspondent à window et document :

(function(w,d,undefined){
 
	// ici le code JS qui va suivre
 
}(window, document));

Le code que l’on va ainsi exécuter sera protégé des autres scripts pour éviter les conflits.
Placez donc le code qui suit à la place du commentaire du code précédent.

var el_html = d.documentElement,
	el_body = d.getElementsByTagName('body')[0],
	header = d.getElementById('header'),
	menuIsStuck = function() {
		// Nous allons compléter notre code ici
	},
	onScrolling = function() {
		// on exécute notre fonction menuIsStuck()
		// dans la fonction onScrolling()
		menuIsStuck();
		// on pourrait faire plein d'autres choses ici 
	};
 
// quand on scroll
w.addEventListener('scroll', function(){
	// on exécute la fonction onScrolling()
	w.requestAnimationFrame( onScrolling );
});

Voyons ce code ensemble.
Les 11 premières lignes (le premier gros bloc) servent à déclarer quatre variables, dont deux sont en fait des fonctions.
el_html correspond au nœud <html> et header est le nœud de notre en-tête que nous allons positionner en sticky par la suite.

La fonction menuIsStuck va nous permettre de faire les calculs de positionnement dans la page pour coller/décoller notre en-tête au bon moment. C’est dans cette fonction que nous placerons le prochain bout de code JS.
La fonction onScrolling va se déclencher au moment du scroll dans la page, et exécutera à son tour la fonction menuIsStuck. J’ai l’habitude d’encapsuler certaines fonctions dans une plus globale, notamment lorsque j’ai besoin d’exécuter plusieurs fonctions lors du même évènement, et/ou lorsque certaines variables sont partagées entre les fonctions.

Le bloc qui suit sert à déclarer l’écouteur d’évènement, ici pour l’évènement scroll. Dès qu’un scroll est effectué, la fonction requestAnimationFrame() va exécuter la fonction onScrolling. Cette première fonction permet d’exécuter aussi souvent que possible une action suivant les capacités du navigateur.

Complétons notre fonction menuIsStuck en remplaçant le commentaire par ce code :

var wScrollTop	= w.pageYOffset || el_body.scrollTop,
	regexp		= /(nav\-is\-stuck)/i,
	classFound	= el_html.className.match( regexp ),
	navHeight	= header.offsetHeight,
	bodyRect	= el_body.getBoundingClientRect(),
	scrollValue	= 600;
 
// si le scroll est d'au moins 600 et
// la class nav-is-stuck n'existe pas sur HTML
if ( wScrollTop > scrollValue && !classFound ) {
	el_html.className = el_html.className + ' nav-is-stuck';
	el_body.style.paddingTop = navHeight + 'px';
}
 
// si le scroll est inférieur à 2 et
// la class nav-is-stuck existe
if ( wScrollTop < 2 && classFound ) {
	el_html.className = el_html.className.replace( regexp, '' );
	el_body.style.paddingTop = '0';
}

Nous avons ici trois blocs principaux de code : une déclaration de variables diverses et variées, et deux contrôles.

La déclaration réunit tous nos besoins en calculs et variables.

  • wScrollTop est la position de notre scroll dans la page.
  • regexp permet d’enregistrer l’expression régulière qui va nous permettre de détecter la classe CSS nav-is-stuck que nous allons utiliser.
  • classFound enregistre si oui ou non l’élément HTML est porteur de notre classe CSS.
  • navHeight enregistre la hauteur de notre header (oui ça peut varier).
  • bodyRect nous retourne plusieurs valeurs liée à notre élément body (comme la largeur, la hauteur, la position dans la page, etc.).
  • scrollValue est la distance que nous avons choisi pour déclencher l’effet sticky, c’est à dire ici 600px.

Une fois ces variables enregistrées ou mises à jour (elles le seront pour certaines à chaque mouvement de scroll), nous effectuons deux contrôles qui ne peuvent théoriquement pas être « vrais » en même temps.

Le premier contrôle permet de vérifier lorsque l’on arrive au seuil de scroll que l’on a déclaré. Dès que c’est le cas, on ajoute la class nav-is-stuck sur l’élément HTML et on ajoute un padding à body équivalent à la hauteur du header. En effet si nous ne faisons pas cet ajout de padding, nous allons nous retrouver avec un effet du bord (un saut bizarre) dû à la sortie du flux du header. (voir la vidéo ci-dessus)

Le second contrôle permet de vérifier lorsque l’on arrive en haut de la page. Dès que c’est le cas, on retire la classe et le padding pour replacer le header dans le flux, à sa position d’origine.

Dans les deux cas, avant d’ajouter la classe et le padding, on vérifie simplement que la classe n’est pas déjà présente, ça évite de faire l’action inutilement. Idem dans l’autre sens, avant de la retirer, on vérifie si elle n’a pas déjà été retirée.

Voici le code JS complet pour cette partie, sans le polyfill.

(function(w,d,undefined){
 
	var el_html = d.documentElement,
		el_body = d.getElementsByTagName('body')[0],
		header = d.getElementById('header'),
		menuIsStuck = function() {


			var wScrollTop	= w.pageYOffset || el_body.scrollTop,
				regexp		= /(nav\-is\-stuck)/i,
				classFound	= el_html.className.match( regexp ),
				navHeight	= header.offsetHeight,
				bodyRect	= el_body.getBoundingClientRect(),
				scrollValue	= 600;
 
			// si le scroll est d'au moins 600 et
			// la class nav-is-stuck n'existe pas sur HTML
			if ( wScrollTop > scrollValue && !classFound ) {
				el_html.className = el_html.className + ' nav-is-stuck';
				el_body.style.paddingTop = navHeight + 'px';
			}
 
			// si le scroll est inférieur à 2 et
			// la class nav-is-stuck existe
			if ( wScrollTop < 2 && classFound ) {
				el_html.className = el_html.className.replace( regexp, '' );
				el_body.style.paddingTop = '0';
			}

		},
		onScrolling = function() {
			// on exécute notre fonction menuIsStuck()
			// dans la fonction onScrolling()
			menuIsStuck();
			// on pourrait faire plein d'autres choses ici 
		};
 
	// quand on scroll
	w.addEventListener('scroll', function(){
		// on exécute la fonction onScrolling()
		w.requestAnimationFrame( onScrolling );
	});
 
}(window, document));

C’est long à expliquer pour moi et à intégrer peut-être pour vous, mais finalement cela ne fait pas beaucoup de code à écrire 🙂

Ok… mais on a toujours pas de sticky avec ça, juste un changement de classe CSS et un padding en plus. Passons au complément de CSS.

Notre menu sticky animé

Nous allons utiliser uniquement du CSS pour faire notre animation, et si jamais l’animation CSS n’est pas comprise par le navigateur (ce qui devient rare), et bien il n’en aura pas, tout simplement.

.nav-is-stuck .main-header {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
	animation: stickAnim .3s;
}
 
@keyframes stickAnim {
	0% {
		transform: translateY(-86px);
	}
	100% {
		transform: translateY(0);
	}
}

Dès que l’élément HTML possède la classe nav-is-stuck, je peux cibler le header grâce au sélecteur de descendance et le fixer. Tout en le fixant, je lui attribue l’animation stickAnim déclarée juste après.
Cette animation fait faire une translation CSS en Y de -86px (environ la hauteur du menu) vers 0, son emplacement d’origine. Cela nous donne l’effet exact de la démo.

Et voilà, vous êtes arrivés au bout de ce tutoriel !

Pour les plus courageux, nous allons voir un petit complément de notre code JS pour définir le déclenchement du sticky seulement lorsque le scroll atteint un autre élément de la page.

Bonus : Déclencher le sticky quand il atteint un élément

Il va s’agir pour nous de rendre dynamique la valeur de la variable scrollValue en la remplaçant par la valeur en pixel de l’élément HTML déclencheur. Dans mon code de démo, j’ai défini plusieurs autres sections et éléments HTML. Je vous invite à faire de même. Attribuez un identifiant (attribut id) à l’un de ces éléments, nous en aurons besoin.

Dans la fonction onScrolling(), repérez l’appel à menuIsStuck() et ajoutez en paramètre d.getElementById('VOTRE_ID')VOTRE_ID est l’identifiant de l’élément HTML déclencheur.
La fonction ressemble alors à cela :

onScrolling = function() {
	menuIsStuck( d.getElementById('main') );
};

Il faut que nous ajoutions ce paramètre à notre fonction menuIsStuck (plus haut dans le code). La ligne qui déclare la fonction ressemble alors à cela :

menuIsStuck = function(triggerElement) {

Cette variable triggerElement est récupérable en l’état dans notre code à l’intérieur de la fonction. Il ne nous reste plus qu’à éditer la valeur de la variable srollValue comme suit :

scrollValue	= triggerElement ? triggerElement.getBoundingClientRect().top - bodyRect.top - navHeight  : 600,

Ça vous parle ? Je pourrais comprendre que non. Mais détaillons. Il s’agit d’un opérateur ternaire, qui est représenté sous cette forme :

variable = condition ? valeur_si_vrai : valeur_si_faux

Dans notre cas on vérifie que triggerElement vaut bien quelque chose, si c’est le cas on fait un calcul savant pour récupérer la valeur « top » de l’élément (le haut quoi), autrement on utilise notre valeur arbitraire de 600 pixels.

C’est tout !

Bonus : Faire apparaître le menu quand on remonte dans la page

Le deuxième petit bonus que je vous avais proposé était de ne faire apparaître le menu en sticky que lorsque l’utilisateur décide de remonter dans la page, donc quand il scroll vers le haut.

Démonstration

Nous allons devoir à nouveau éditer notre code JS, mais tout ne va pas être identique au précédent. Commençons par éditer légèrement notre déclaration de variables en tout début de code.

Vous avez normalement la même chose au début que ce bloc de code, ajoutez simplement la ligne concernant le lastScroll.

var el_html = d.documentElement,
	el_body = d.getElementsByTagName('body')[0],
	header = d.getElementById('header'),
	lastScroll = w.pageYOffset || el_body.scrollTop,

Cela nous permet d’initialiser la valeur de cette variable. Souvent cela correspondra à 0, soit le haut de la page.
Maintenant nous allons utiliser cette variable dans la fonction onScrolling() comme suit :

onScrolling = function() {
	// on récupère la valeur du scroll maintenant
	var wScrollTop = w.pageYOffset || el_body.scrollTop;
		
	// on ajoute deux arguments, valeurs de scrolls
	menuIsStuck( d.getElementById('main'), wScrollTop, lastScroll );
			
	// on enregistre notre dernière valeur de scroll
	lastScroll = wScrollTop;
			
};

Ici on ajoute une ligne avant et une ligne après notre appel à menuIsStuck() pour récupérer la valeur courante du scroll, puis enregistrer cette valeur comme ancienne valeur de scroll, cela va nous permettre de savoir si on monte ou descend lorsque l’on bouge dans la page.

Notre fonction menuIsStuck() accueille maintenant 3 paramètres : l’élément cible, la valeur de scroll et la valeur de scroll précédente. Il faut donc éditer notre fonction pour utiliser ces paramètres.

menuIsStuck = function(triggerElement, wScrollTop, lastScroll) {

Sur la ligne de la déclaration, vous ajoutez donc ces deux paramètres.
Juste en-dessous, retirez la déclaration de wScrollTop, elle fait doublon maintenant, on l’a déjà en paramètre. (n’effacez pas le var)

Conservez tout le reste, il nous faut juste modifier nos deux contrôles (les if). En gros il faut que l’on ajoute les conditions « si on remonte » et « si on descend ». On va faire apparaître notre menu uniquement si on descend, et qu’on a atteint notre seuil (même seuil qu’avant), notre premier contrôle va donc se transformer en :

if ( wScrollTop > scrollValFix && !classFound && wScrollTop < lastScroll ) {

Ici wScrollTop < lastScroll permet de dire « on remonte ».

Le second contrôle va ressembler à ça :

if ( classFound && wScrollTop > lastScroll ) {

Et voilà, notre sticky menu n’apparait désormais que lorsque l’on remonte dans la page, et disparait si l’on descend.

J’espère que ce long tutoriel vous aura plu. N’hésitez pas à commenter pour nous partager vos essais et vos démos.

Et si coder vous ennuie, il existe le bon plugin Headroom.js. (merci @IamNotCyril pour la suggestion)

Sources et liens utiles