En-tête sticky qui se cache au défilement : approche CSS d’abord + fallback JS minimal

Cet article vous a aidé ?

En-tête sticky qui se cache au défilement : approche CSS d’abord + fallback JS minimal

Vous avez livré un en-tête sticky. Le marketing a adoré. Les utilisateurs moins. Maintenant chaque défilement ressemble à pousser un réfrigérateur en montée : saccadé, cahotant, et parfois couvrant l’interface qu’il est censé faciliter.

L’objectif ici est simple : un en-tête disponible quand l’utilisateur remonte (utile), et qui se retire quand l’utilisateur descend (poli). Nous le ferons en privilégiant le CSS, car le CSS est stable sous charge. Puis nous ajouterons un tout petit fallback JavaScript pour les parties que le CSS ne peut pas deviner : la direction du défilement et « avons-nous réellement passé le sommet ? ».

Ce que vous voulez réellement (et ce que vous ne voulez pas)

Un « en-tête sticky qui se cache au défilement » ressemble à une fantaisie de design. En production, c’est une boucle de contrôle. Vous mesurez la position du défilement, en déduisez l’intention de l’utilisateur, et modifiez la mise en page ou le rendu en conséquence. C’est un problème de fiabilité déguisé.

Voici la spécification pratique que je recommande :

  • Au sommet de la page : l’en-tête est visible, pas élevé (pas d’ombre), et ne « rebondit » pas.
  • En défilement vers le bas : l’en-tête se cache après un petit seuil (éviter le scintillement lors de micro-défilements).
  • En remontant : l’en-tête réapparaît rapidement (la navigation et la recherche redeviennent utiles).
  • Ancrages / navigation interne : les titres de contenu ne doivent pas être masqués par l’en-tête.
  • Réduction du mouvement : pas d’animation glissante si l’OS a demandé moins de mouvement.
  • Mobile Safari : pas de tremblement, pas de piège de tap, respecter les zones sûres.
  • Budget CLS nul : l’en-tête ne doit pas provoquer de décalage de mise en page une fois rendu.

Ce que vous ne voulez pas :

  • Cacher/afficher en modifiant la height ou le display pendant le défilement. C’est du travail de mise en page. Vous le payerez à chaque frame.
  • Un gestionnaire de défilement qui fait plus que définir un booléen. Le navigateur a déjà pour tâche principale de dessiner des pixels.
  • « Ça marche sur mon MacBook Pro » comme critère de performance. Le téléphone moyen ne partage pas vos sentiments.

Une règle à tatouer dans votre processus de revue de code : animez des transforms, pas la mise en page. Si votre implémentation provoque un thrash de mise en page, elle finira par rencontrer une page lourde et perdra.

Faits et brève histoire : pourquoi c’est plus difficile qu’il n’y paraît

Les en-têtes sticky semblent un problème résolu parce que vous les avez vus mille fois. Sous le capot, c’est une poignée de main entre la mécanique du défilement, le compositeur, les bizarreries de la fenêtre d’affichage et les attentes d’accessibilité. Quelques points de contexte utiles pour le débogage nocturne :

  1. position: sticky a été standardisé après des années d’expérimentations fournisseurs. Le comportement « sticky » initial dépendait souvent de bibliothèques JS qui polyfillaient tout via des écouteurs de scroll.
  2. Les navigateurs mobiles ont en pratique deux « viewports ». Le viewport de mise en page et le viewport visuel peuvent différer (surtout avec la réduction de la barre d’adresse), ce qui affecte les attentes autour de « top: 0 ».
  3. iOS Safari a historiquement eu du mal avec fixed/sticky pendant le rebond d’overscroll. Même aujourd’hui, les overscrolls et la barre d’outils dynamique peuvent produire des jitter.
  4. Les événements de scroll étaient autrefois synchrones et coûteux. Les navigateurs sont passés au défilement asynchrone pour garder l’UI réactive, d’où la nécessité que les handlers modernes soient légers et souvent passifs.
  5. IntersectionObserver a été introduit pour éviter le polling constant du scroll. C’est important pour la logique « suis-je passé un sentinel ? » sans brûler le CPU pixel par pixel.
  6. Core Web Vitals met des chiffres derrière le « ressenti ». CLS et INP vous trahiront même si votre QA n’a pas remarqué le jitter.
  7. Le « scroll anchoring » existe pour prévenir les sauts de contenu. Mais les en-têtes qui changent de hauteur peuvent le défaire et réintroduire des sauts que les utilisateurs perçoivent comme des bugs.
  8. Les insets de zone sûre sont devenus une préoccupation web avec les téléphones à encoche. Si votre en-tête ignore env(safe-area-inset-top), vous aurez du contenu rogné sur certains appareils.

Blague #1 : Un en-tête sticky, c’est comme un quota de stockage — personne n’y prête attention tant que ça marche, puis soudain c’est la priorité numéro un.

CSS en priorité : sticky, offsets sûrs et pas de décalages de mise en page

L’approche CSS-first signifie : obtenir 80 % du comportement sans JavaScript. Cela vous donne une base stable : pas de dépendance aux handlers de scroll, pas de surprises lorsque le thread principal est occupé, et moins d’incidents du type « ça n’arrive que sur cette page ».

CSS de base pour l’en-tête (sticky + animation par transform)

Utilisez position: sticky et gardez la hauteur de l’en-tête constante. Pour le cacher, translatez-le hors de la vue avec transform. C’est favorable au compositing et évite généralement le recalcul de mise en page.

L’en-tête de cette page implémente déjà la base : position sticky, hauteur constante, masquage par transform, padding safe-area, et :target pour éviter le recouvrement d’ancrage.

Empêcher les cibles d’ancrage d’être masquées par l’en-tête

Si votre navigation interne utilise des targets #hash (liens TOC, « aller à la section »), le navigateur place la cible en haut. Avec un en-tête sticky, « le haut » est désormais derrière une plaque d’UI.

La solution low-tech est solide : scroll-margin-top sur les titres ou une règle globale :target. C’est ce que nous utilisons :

cr0x@server:~$ cat ui.css | sed -n '1,40p'
:target {
  scroll-margin-top: calc(var(--header-h) + 16px);
}

Sens : vous ajoutez une marge de défilement pour tout élément ciblé par une navigation par fragment. Décision : appliquez globalement si la structure du document est cohérente ; sinon, ciblez h2, h3 pour éviter des offsets étranges sur d’autres cibles.

Ne laissez pas le contenu sauter quand l’en-tête « se cache »

Si cacher l’en-tête change sa hauteur, le contenu de la page monte. C’est le CLS classique. Les utilisateurs perçoivent ça comme « le site bouge sous mon doigt ». Gardez la hauteur fixe. Cachez avec transform.

Cela évite aussi un mode d’échec où l’en-tête se cache, le contenu se décale, et le navigateur essaie de garder l’ancre de défilement actuelle visible, provoquant des sauts supplémentaires. C’est comme deux systèmes de contrôle qui se battent en plein vol.

Respectez la réduction de mouvement par défaut

Les en-têtes glissants peuvent déclencher du mouvement chez certains utilisateurs. Rendez l’effet instantané si prefers-reduced-motion: reduce. Vous pouvez toujours basculer la visibilité ; évitez simplement la transition animée.

Trois modèles viables (choisissez-en un volontairement)

Modèle A : « Sticky toujours visible » (CSS-only, ennuyeux, fiable)

Ce n’est pas ce que promet le sujet, mais c’est la base par laquelle commencer. Un en-tête sticky qui ne se cache jamais a moins de pièces mécaniques et est généralement mieux accessible. Si votre en-tête est haut ou le contenu dense, cela peut être la meilleure décision produit.

  • Avantages : le plus simple, moins de saccades, facile à raisonner.
  • Inconvénients : consomme de l’espace vertical, particulièrement pénible sur petits écrans.

Modèle B : « Masquer en descendant, montrer en montant » (JS minimal, meilleur compromis)

C’est le comportement standard attendu par les utilisateurs car il reflète l’intention : quand ils lisent vers le bas, dégagez le passage ; si ils inversent la direction, ils veulent probablement la navigation.

  • Avantages : fonctionne partout, prévisible, ajustable via des seuils.
  • Inconvénients : nécessite du JS pour inférer la direction ; il faut faire attention à la performance.

Modèle C : « Masquer après sentinel, montrer près du sommet » (IntersectionObserver + direction optionnelle)

Si vous détestez les écouteurs de scroll (raisonnable), utilisez un élément sentinel près du haut. Quand il sort du viewport, élevez l’en-tête (ombre) et activez éventuellement la logique de masquage. Cela rend l’état « en haut de la page » robuste.

  • Avantages : moins de calculs de scroll ; détection stable du « suis-je en haut ? ».
  • Inconvénients : nécessite toujours une détection de direction pour le comportement complet hide-on-down ; peut se compliquer avec les barres d’outils dynamiques.

Fallback JS minimal : direction, seuils et état

Le CSS ne peut pas connaître la direction du défilement. Il peut réagir à l’état que vous définissez. La bonne forme est donc : le JS lit la position de scroll, définit quelques attributs data, puis s’efface.

Gardez la machine d’état minuscule :

  • data-hidden : booléen
  • data-elevated : booléen (ombre une fois que vous êtes loin du sommet)

Un script minimal adapté à la production

C’est le JS minimal que je suis prêt à défendre en revue de performance. Il utilise requestAnimationFrame pour consolider les événements de scroll, un seuil pour éviter le scintillement, et évite de faire du travail quand rien n’a changé.

cr0x@server:~$ cat sticky-header.js
(() => {
  const header = document.querySelector('.site-header');
  if (!header) return;

  const hideThreshold = 12;      // px of downward movement before hiding
  const showThreshold = 6;       // px of upward movement before showing
  const elevateAfter = 4;        // px from top before adding shadow
  const topSnap = 0;             // treat 0 as top; adjust for visual viewport if needed

  let lastY = window.scrollY || 0;
  let lastDir = 0;               // -1 up, +1 down
  let rafPending = false;

  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));

  function update() {
    rafPending = false;
    const y = window.scrollY || 0;
    const dy = y - lastY;

    // Determine direction with deadzone to avoid noise.
    let dir = lastDir;
    if (dy >= hideThreshold) dir = 1;
    else if (dy <= -showThreshold) dir = -1;

    // Elevated when not at top.
    const elevated = y > elevateAfter;

    // Hide only when scrolling down and not near top.
    let hidden = header.dataset.hidden === 'true';
    if (y <= topSnap) {
      hidden = false;
    } else if (dir === 1) {
      hidden = true;
    } else if (dir === -1) {
      hidden = false;
    }

    // Apply only on changes.
    if ((header.dataset.elevated === 'true') !== elevated) {
      header.dataset.elevated = elevated ? 'true' : 'false';
    }
    if ((header.dataset.hidden === 'true') !== hidden) {
      header.dataset.hidden = hidden ? 'true' : 'false';
    }

    lastY = y;
    lastDir = dir;
  }

  function onScroll() {
    if (rafPending) return;
    rafPending = true;
    requestAnimationFrame(update);
  }

  window.addEventListener('scroll', onScroll, { passive: true });

  // Run once on load in case the page loads mid-scroll.
  update();
})();

Sens : vous avez maintenant un basculeur déterministe qui ne s’exécutera pas plus d’une fois par image. Décision : utilisez cette forme exacte si vous tenez à la performance ; ne laissez pas le code s’étendre.

Sentinel IntersectionObserver : état « sommet » robuste sans deviner

Les bugs les plus étranges viennent de la logique « suis-je en haut ? » avec les barres d’outils mobiles. Un élément sentinel en haut du contenu vous donne un signal net : s’il intersecte, vous êtes près du sommet.

Vous pouvez combiner le sentinel avec le script de direction ci-dessus, ou l’utiliser seulement pour l’élévation et éviter le scintillement d’ombre.

cr0x@server:~$ cat sentinel.js
(() => {
  const header = document.querySelector('.site-header');
  const sentinel = document.querySelector('[data-top-sentinel]');
  if (!header || !sentinel || !('IntersectionObserver' in window)) return;

  const io = new IntersectionObserver((entries) => {
    const e = entries[0];
    // When sentinel is visible, we're near the top: no shadow.
    header.dataset.elevated = e.isIntersecting ? 'false' : 'true';
  }, { root: null, threshold: [0, 1] });

  io.observe(sentinel);
})();

Sens : l’état d’élévation est désormais piloté par l’intersection, pas par des heuristiques sur scrollY. Décision : préférez ceci si vous avez des bannières dynamiques en haut, des barres qui se contractent, ou une mise en page compliquée où « sommet » n’est pas simplement scrollY==0.

Blague #2 : Si vous attachez trois écouteurs de scroll, le navigateur ne « multithread » pas, il vous juge silencieusement.

Accessibilité et règles « ne cassez pas le bouton Précédent »

Cacher un en-tête est un choix UX. Si vous l’implémentez sans soin, cela devient un défaut d’accessibilité.

Clavier et focus : ne jamais cacher des contrôles focalisés

Si l’en-tête contient un champ de recherche ou des liens de navigation, l’utilisateur peut y accéder au clavier. Si votre script cache l’en-tête alors qu’un contrôle à l’intérieur a le focus, vous créez une interaction « maintenant vous le voyez, maintenant vous ne le voyez plus » hostile aux utilisateurs clavier.

Correction : si header.contains(document.activeElement) est vrai, forcez la visibilité. C’est une petite condition qui prévient une classe de bug étonnamment méchante.

cr0x@server:~$ rg "activeElement" -n sticky-header.js

Sens : aucune ligne trouvée indique que vous n’avez pas ajouté la protection de focus. Décision : ajoutez-la si l’en-tête contient des éléments interactifs (ce qui est presque toujours le cas).

Lecteurs d’écran : évitez de retirer du contenu de l’arbre d’accessibilité

Glisser l’en-tête hors écran avec transform le laisse dans le DOM et dans l’arbre d’accessibilité. C’est généralement acceptable. N’utilisez pas display: none comme mécanisme principal si vous ne gérez pas le focus, les états aria et les conséquences de reflow.

Si vous devez entièrement le cacher, gérez le focus avec précaution et envisagez inert (là où supporté) pour empêcher la navigation clavier dans des contrôles hors écran. Mais là, vous recréez un framework UI. Essayez d’éviter.

La réduction de mouvement n’est pas optionnelle

Vous avez déjà vu le CSS. Conservez-le. Envisagez aussi d’abandonner complètement la logique hide/show basée sur la direction en cas de réduction de mouvement si le produit le permet. Une UI qui réagit au scroll peut donner l’impression d’une page « vivante ». Certains utilisateurs ne souhaitent pas une page vivante.

Ne cassez pas la restauration de scroll du navigateur

Le navigateur tente de restaurer la position de défilement lors de la navigation arrière/avant. Si votre en-tête change de hauteur ou déclenche une mise en page durant le paint initial, vous pouvez obtenir un comportement « restauration au mauvais endroit puis saut ».

Bonne pratique : gardez la mise en page de l’en-tête stable dès la première frame. Évitez de charger une grosse webfont tard qui change la hauteur de l’en-tête. Si vous ne pouvez pas l’éviter, fixez des hauteurs explicites et utilisez font-display pour ne pas reflower l’en-tête.

Performance : d’où vient la saccade (et comment l’éliminer)

La performance du défilement est un budget. Le navigateur vise ~60fps sur du matériel courant. Cela vous donne environ 16ms par frame, partagé entre tout : mise en page, peinture, compositing, JS, images, pubs, analytics, etc.

Pourquoi les transforms gagnent généralement

Une animation transform: translateY() peut souvent être traitée par le thread de composition. Cela signifie qu’elle peut continuer même si le thread principal est occupé. « Souvent » nécessite du travail ; vous devez quand même éviter de forcer la mise en page et les peintures lourdes.

Les trois sources principales de saccade pour les en-têtes hide-on-scroll

  • Thrash de mise en page : basculer des propriétés comme height, top ou des classes qui reflowent la page à chaque événement de scroll.
  • Surcharge du thread principal : le gestionnaire de scroll fait trop ou déclenche des recalculs de style répétés.
  • Tempêtes de peinture : ombres, flous et fonds translucides qui repassent de larges zones lors de transforms sur des GPUs modestes.

Rendez les ombres conditionnelles, pas constantes

Une grosse ombre rend bien. Elle peut aussi être coûteuse, surtout sur mobile. Appliquez-la uniquement quand vous êtes loin du sommet, et envisagez des ombres plus simples pour les appareils bas de gamme. L’approche avec l’attribut « elevated » vous donne un toggle propre.

Utilisez des listeners passifs et requestAnimationFrame

Les listeners de scroll passifs disent au navigateur que vous n’appellerez pas preventDefault(), il peut donc scroller sans vous attendre. Le batching avec requestAnimationFrame évite de faire du travail redondant entre les frames.

« L’espoir n’est pas une stratégie. » —General H. Norman Schwarzkopf

Cela s’applique aussi à la fiabilité UI. Si vous « espérez » que votre handler de scroll est correct parce qu’il est petit, vous déploierez une régression quand quelqu’un ajoutera des appels analytics ou des requêtes DOM dans la même boucle.

Trois mini-histoires d’entreprise (ce dont on apprend)

Mini-histoire 1 : L’incident causé par une mauvaise hypothèse

Une équipe dashboard interne a déployé un nouvel en-tête global avec comportement « hide on scroll ». C’était censé aider les analystes à voir plus de lignes dans un tableau dense. L’implémentation avait l’air propre : un écouteur de scroll comparait window.scrollY à la dernière valeur et basculait display: none sur l’en-tête.

L’hypothèse erronée était subtile : « Cacher l’en-tête revient au même que le traduire hors champ. » Dans leur modèle mental l’en-tête était purement visuel. Dans le modèle du navigateur, changer le display affecte la mise en page, ce qui affecte la hauteur du scroll, ce qui déclenche d’autres événements de scroll.

Sur des pages avec tableaux virtualisés, la suppression de l’en-tête changeait la hauteur du viewport disponible. Le tableau recalculait le rendu des lignes. Cela déclenchait une mise en page. La mise en page changeait la position de scroll légèrement. Le handler de scroll détectait le mouvement et basculait à nouveau. Le résultat n’était pas une boucle infinie, mais un scintillement violent qui a fait grimper la CPU et rendu la page inutilisable.

Cela ne se reproduisait que sur certaines machines car la performance déterminait si l’oscillation s’amortissait ou s’amplifiait. Le rapport d’incident a conclu avec la correction la moins glamour de l’histoire : garder l’en-tête dans le flux, le cacher avec transform, et ajouter un seuil en pixels pour ignorer le bruit. Personne n’a été promu pour ça, mais les graphiques ont cessé de hurler.

Mini-histoire 2 : L’optimisation qui a échoué

Un site produit voulait un défilement fluide sur des Android bas de gamme. Quelqu’un a proposé « GPU-accélérez tout » et a ajouté will-change: transform à l’en-tête, au hero, au rail CTA et quelques autres composants. L’idée : promouvoir des éléments en couches, éviter la saccade.

Pendant quelques jours cela semblait mieux sur quelques devices dev. Puis le monitoring réel a montré une mémoire GPU plus élevée et plus d’événements « tab reload » sur mobile. La promotion vers des couches a augmenté la pression mémoire GPU, et sur certains appareils le navigateur a commencé à récupérer des ressources de manière agressive.

L’en-tête était lui-même OK. Le problème était systémique : will-change n’est pas une baguette magique ; c’est un indice qui coûte de la mémoire. Trop d’éléments promus peuvent faire thrash le compositeur ou déclencher des uploads de tuiles. L’« optimisation » est devenue un problème de fiabilité.

La correction a été d’utiliser will-change uniquement sur l’en-tête (et seulement quand nécessaire), simplifier l’ombre, et l’enlever du reste. Le défilement est redevenu ennuyeux, ce qui est le plus grand compliment pour une UI en production.

Mini-histoire 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise

Une grande application d’entreprise avait une règle : tout comportement UI global devait être déployé derrière un feature flag, avec un kill switch contrôlé par ops. Ce n’était pas sexy. Les ingénieurs levaient parfois les yeux. Puis une mise à jour de navigateur est arrivée.

La mise à jour a changé quelque chose dans le comportement du scroll sur un sous-ensemble d’appareils. L’en-tête hide-on-scroll a commencé à saccader, mais seulement quand un widget tiers embarqué était présent. Le widget injectait un grand élément en position fixe qui modifiait les décisions de composition. Les utilisateurs disaient que l’en-tête « vibre ».

Comme la fonctionnalité était flaggée, l’on-call a désactivé le comportement pour les user agents affectés pendant que l’équipe enquêtait. Le site est resté utilisable. Personne n’a dû faire un hotfix à minuit sous pression. Le lendemain, ils ont corrigé la logique pour éviter de basculer pendant que le widget animait et ajusté les seuils pour ce navigateur.

La leçon est terne et durable : déployez les comportements UI avec un moyen de les désactiver. Pas parce que vous attendez une panne, mais parce que la réalité est inventive.

Tâches pratiques : commandes, sorties et décisions

Vous vouliez des tâches concrètes, pas des vibes. Voici les vérifications que j’exécute quand un en-tête « hide on scroll » pose problème en production. Mélange de vérifications serveur (avons-nous déployé les bons assets ?), débogage client (shippons-nous trop ?) et diagnostic de performance.

Tâche 1 : Vérifier que le CSS déployé contient les règles sticky + transform

cr0x@server:~$ grep -nE "position:\s*sticky|will-change:\s*transform|translateY" /var/www/app/static/ui.css | head
132:header.site-header { position: sticky;
139:  will-change: transform;
151:header.site-header[data-hidden="true"] { transform: translateY(calc(-1 * var(--header-h)));

Sens : les propriétés clés existent dans l’artefact déployé. Décision : si elles manquent, votre pipeline a probablement livré un ancien bundle ou un thème différent ; corrigez le déploiement avant de déboguer la « performance ».

Tâche 2 : Confirmer que la hauteur de l’en-tête est constante dans le CSS (pas de height animée)

cr0x@server:~$ grep -nE "header\.site-header|height:" -n /var/www/app/static/ui.css | sed -n '120,175p'
132:header.site-header {
145:  height: var(--header-h);
151:header.site-header[data-hidden="true"] {

Sens : la hauteur est définie une fois, non basculée. Décision : si vous voyez des changements de hauteur selon les états, attendez-vous à des shifts et des reflows ; refactorez vers des transforms.

Tâche 3 : Valider que le bundle JS contient bien le gestionnaire de scroll minimal

cr0x@server:~$ rg -n "requestAnimationFrame\\(update\\)|passive:\\s*true|data-hidden" /var/www/app/static/app.js | head
8432:    requestAnimationFrame(update);
8440:  window.addEventListener('scroll', onScroll, { passive: true });
8456:    header.dataset.hidden = hidden ? 'true' : 'false';

Sens : les parties importantes sont présentes. Décision : si vous ne voyez pas de listeners passifs ou de rAF, vous faites probablement trop de travail par événement de scroll ; corrigez cela d’abord.

Tâche 4 : Vérifier l’efficacité gzip/brotli pour JS/CSS (moins de données envoyées compte)

cr0x@server:~$ curl -sI -H 'Accept-Encoding: br' http://localhost/static/app.js | grep -iE 'content-encoding|content-length|cache-control'
Content-Encoding: br
Content-Length: 182943
Cache-Control: public, max-age=31536000, immutable

Sens : brotli est activé ; la taille est visible ; le cache est long. Décision : si pas de compression ou pas de cache, corrigez cela avant d’optimiser les calculs de scroll.

Tâche 5 : Confirmer les bons types MIME (évite des comportements navigateur étranges)

cr0x@server:~$ curl -sI http://localhost/static/ui.css | grep -iE 'content-type|cache-control'
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=31536000, immutable

Sens : type MIME et cache corrects. Décision : si le MIME est incorrect, certains navigateurs traitent les assets différemment ; corrigez la config serveur.

Tâche 6 : Détecter des écouteurs de scroll dupliqués dans le bundle

cr0x@server:~$ rg -n "addEventListener\\('scroll'" /var/www/app/static/app.js | head -n 20
8440:  window.addEventListener('scroll', onScroll, { passive: true });
12110: window.addEventListener('scroll', trackScrollDepth, { passive: true });
17822: document.addEventListener('scroll', legacyScrollHandler);

Sens : plusieurs écouteurs de scroll existent. Décision : auditez-les. Si vous voyez un « legacyScrollHandler », vous avez probablement des comportements concurrents et du travail inutile ; supprimez ou gatez derrière des flags.

Tâche 7 : Confirmer que la page n’oblige pas de mise en page pendant le scroll (trouvez le code de triggering de layout)

cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight|clientHeight" /var/www/app/static/app.js | head
5209:  const h = header.offsetHeight;
10902: const rect = el.getBoundingClientRect();

Sens : ces appels peuvent déclencher la mise en page s’ils sont mélangés à des écritures. Décision : assurez-vous que ces lectures ne sont pas dans le handler de scroll ou sont isolées avant les écritures ; sinon vous créerez des layouts synchrones forcés.

Tâche 8 : Vérifier les logs d’accès Nginx pour le churn d’assets (les utilisateurs re-téléchargent-ils ?)

cr0x@server:~$ sudo awk '$7 ~ /\/static\/(app\.js|ui\.css)/ {print $7, $9}' /var/log/nginx/access.log | tail -n 8
/static/ui.css 200
/static/app.js 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200

Sens : beaucoup de 200 suggèrent que le caching est cassé (on devrait voir des 304 ou des hits CDN). Décision : vérifiez les headers de cache et la config CDN ; les téléchargements répétés retardent l’interactivité et peuvent aggraver la saccade après navigation.

Tâche 9 : Vérifier les temps de réponse du serveur pour le HTML (TTFB lent retarde CSS/JS)

cr0x@server:~$ curl -o /dev/null -s -w "ttfb=%{time_starttransfer} total=%{time_total}\n" http://localhost/
ttfb=0.043 total=0.051

Sens : TTFB et total rapides. Décision : si le TTFB est élevé, votre « jitter d’en-tête » peut être un symptôme de CSS/JS chargés tardivement à cause d’un HTML lent ; corrigez le backend ou le caching d’abord.

Tâche 10 : Valider que vous ne déployez pas des scripts tiers non bornés

cr0x@server:~$ rg -n "googletagmanager|segment|hotjar|fullstory|datadogRum" /var/www/app/templates/index.html
42:<script>/* datadogRum init */</script>

Sens : un runtime tiers est présent. Décision : si le scroll se dégrade après initialisation des analytics, vous devrez retarder ces scripts non critiques, échantillonner fortement ou les isoler du chemin de scroll.

Tâche 11 : Vérifier les signaux de layout shift dans les logs outils (style Lighthouse CI)

cr0x@server:~$ jq '.audits["cumulative-layout-shift"].numericValue' ./lighthouse-report.json
0.19

Sens : le CLS est non négligeable. Décision : inspectez si la hauteur de l’en-tête ou le padding top change après le chargement, ou si des polices/bannières tardives poussent le contenu. Corrigez le CLS avant d’affiner l’animation de masquage.

Tâche 12 : Confirmer que l’en-tête n’est pas plus haut sur iOS à cause d’un mauvais calcul de safe-area

cr0x@server:~$ grep -n "safe-area-inset-top" -n /var/www/app/static/ui.css
88:header.site-header { padding-top: env(safe-area-inset-top); height: calc(var(--header-h) + env(safe-area-inset-top)); }

Sens : la zone sûre est gérée explicitement. Décision : si manquant et vous avez des utilisateurs iOS à encoche, ajoutez-le ; une nav tronquée est un générateur de tickets support réel.

Tâche 13 : Valider qu’un kill switch feature-flag existe pour le comportement

cr0x@server:~$ rg -n "HIDE_HEADER_ON_SCROLL|featureFlag.*header" /var/www/app/static/app.js | head
902:  if (!window.__FLAGS__?.HIDE_HEADER_ON_SCROLL) return;

Sens : le comportement peut être désactivé. Décision : si vous n’avez pas cela, vous choisissez de déboguer la production en redéployant. C’est un choix de style de vie, pas d’ingénierie.

Tâche 14 : Vérifier les logs d’erreurs pour des exceptions JS qui laissent l’en-tête bloqué

cr0x@server:~$ sudo journalctl -u nginx -n 50 --no-pager | tail -n 10
Dec 29 10:14:03 web nginx[2213]: 2025/12/29 10:14:03 [warn] 2213#2213: *8930 upstream response is buffered to a temporary file

Sens : c’est côté serveur et pas directement lié au JS client, mais rappel : vérifiez la télémétrie d’erreurs client aussi. Décision : si des exceptions client existent autour du code de scroll, ajoutez des try/catch et « fail open » (en-tête visible).

Si vous vous demandez pourquoi un ingénieur stockage dit de vérifier les headers de cache : c’est parce que la latence et la taille des payloads sont de l’expérience utilisateur, et l’expérience utilisateur est la production.

Procédure de diagnostic rapide

Quand l’en-tête sticky se comporte mal, ne « réglez pas les seuils » en premier. C’est ainsi qu’on perd un après-midi. Diagnosez comme un SRE : isolez, mesurez, réduisez les variables.

Première étape : est-ce un shift de mise en page ou une saccade de scroll ?

  • Vérifier : le contenu bouge-t-il vers le haut/bas quand l’en-tête cache/affiche ?
  • Interprétation : si oui, vous effectuez des changements de mise en page (height/display/margins) ou des assets chargés tardivement qui changent la taille de l’en-tête.
  • Action : rendez la hauteur de l’en-tête constante ; utilisez transform ; définissez des tailles explicites pour polices/icônes.

Deuxième étape : y a-t-il trop d’écouteurs de scroll ou du travail coûteux dedans ?

  • Vérifier : cherchez dans le bundle addEventListener('scroll', mesurez le nombre, trouvez les handlers legacy.
  • Interprétation : plusieurs handlers se concurrencent souvent et déclenchent des lectures/écritures de layout.
  • Action : consolidez en un seul handler rAF-batché ; déplacez les analytics hors du chemin de scroll.

Troisième étape : le compositeur struggle-t-il (paint storms, ombres lourdes, backdrops translucides) ?

  • Vérifier : le saccade coïncide-t-il avec du contenu lourd ou uniquement sur devices bas de gamme ?
  • Interprétation : de gros blurs/ombres sur des éléments en mouvement peuvent être coûteux.
  • Action : simplifiez l’ombre ; évitez backdrop-filter ; réduisez les couches alpha ; n’abusez pas de will-change.

Quatrième étape : est-ce une bizarrerie de viewport mobile (barre d’outils dynamique Safari) ?

  • Vérifier : cela n’arrive-t-il que sur iOS Safari, surtout quand la barre d’adresse se contracte/étend ?
  • Interprétation : la détection scrollY/top peut être bruyante.
  • Action : utilisez un sentinel IntersectionObserver pour l’état « en haut » ; ajoutez des seuils ; évitez de compter sur scrollY==0 exact.

Cinquième étape : est-ce un bug d’interaction (focus, clavier, cibles tactiles) ?

  • Vérifier : tabulez dans les éléments de l’en-tête ; disparaît-il quand ils sont focalisés ?
  • Interprétation : vous cachez sans considérer l’état d’interaction/focus.
  • Action : forcez la visibilité quand l’en-tête contient document.activeElement ; envisagez un court verrou après les interactions.

Erreurs fréquentes : symptôme → cause → correction

1) Symptom : l’en-tête clignote rapidement sur trackpads ou tactile

Cause : la détection de direction n’a pas de deadzone ; de petites deltas basculent l’état constamment.

Correction : ajoutez des seuils séparés pour hide/show ; conservez la dernière direction jusqu’à dépasser le seuil ; mettez à jour l’état en rAF.

2) Symptom : le contenu « saute » quand l’en-tête cache ou montre

Cause : le masquage modifie la mise en page (height/display/margins) ou des polices/icônes chargées tardivement changent la taille de l’en-tête.

Correction : gardez la hauteur de l’en-tête constante ; utilisez transform ; définissez des hauteurs explicites ; évitez les reflows tardifs dans l’en-tête.

3) Symptom : l’en-tête recouvre les titres de section après clic sur les liens TOC

Cause : pas de gestion d’offset d’ancrage.

Correction : utilisez scroll-margin-top sur les titres ou :target avec la hauteur de l’en-tête.

4) Symptom : l’en-tête reste caché après retour en arrière

Cause : état restauré incorrectement ; script initialisé avant que l’en-tête existe ; ou exception JS empêche les mises à jour.

Correction : exécutez un update() initial ; fail open (visible) en cas d’erreur ; assurez-vous que le script s’exécute après DOM ready ou utilise defer.

5) Symptom : le défilement est fluide jusqu’à l’initialisation des analytics, puis il devient saccadé

Cause : contention du thread principal ; analytics travaille pendant le scroll ou déclenche des lectures de layout.

Correction : retirez les hooks analytics du chemin de scroll ; échantillonnez les événements ; utilisez IntersectionObserver pour la profondeur de scroll ; différez les scripts non critiques.

6) Symptom : fonctionne sur Chrome desktop, saccade sur iOS Safari

Cause : changements de viewport dynamique ; overscroll de type rubber-band ; différences de composition.

Correction : utilisez un sentinel pour l’état sommet ; évitez les comparaisons exactes à scrollY==0 ; conservez des animations transform-only ; respectez les zones sûres.

7) Symptom : les utilisateurs clavier perdent l’élément focalisé

Cause : l’en-tête se cache alors qu’il est focalisé ; ou l’état caché retire les éléments du layout.

Correction : ne cachez pas quand header.contains(document.activeElement) ; évitez display: none pour le masquage.

8) Symptom : l’en-tête semble « lent » (apparaît tard quand on remonte)

Cause : seuils trop grands ; handler de scroll peu fréquent ; ou travail lourd retardant rAF.

Correction : gardez le seuil d’affichage plus petit que celui de masquage ; réduisez le travail dans le handler ; retirez les lectures DOM coûteuses.

9) Symptom : l’ombre de l’en-tête repasse toute la page pendant le scroll

Cause : ombres lourdes/backdrop-filter sur un élément en mouvement provoquent des peintures coûteuses.

Correction : simplifiez l’ombre ; évitez les filtres de flou ; basculez l’ombre uniquement si nécessaire ; envisagez une bordure au lieu d’une ombre.

10) Symptom : l’en-tête chevauche l’encoche / barre d’état sur iPhones

Cause : zone sûre non gérée.

Correction : ajoutez padding-top: env(safe-area-inset-top) et ajustez la hauteur de l’en-tête en conséquence.

Listes de contrôle / plan étape par étape

Plan d’implémentation étape par étape (faites-le dans cet ordre)

  1. Déployez d’abord un en-tête sticky ennuyeux. position: sticky; top: 0; hauteur fixe, pas de comportement de masquage.
  2. Ajoutez les offsets d’ancrage. Utilisez :target { scroll-margin-top: ... } ou appliquez aux titres.
  3. Ajoutez uniquement l’état « elevated ». Ombre/bordure après avoir quitté le sommet, piloté par un sentinel ou un petit seuil scrollY.
  4. Ajoutez le masquage/affichage uniquement par transform. Pas de changements de hauteur. Pas de toggles display.
  5. Ajoutez la détection de direction avec des seuils. Seuil de masquage plus grand que celui d’affichage.
  6. Protégez les états d’interaction. Ne cachez pas pendant le focus ou une interaction pointer active si nécessaire.
  7. Respectez la réduction de mouvement. Désactivez les transitions (et possiblement le comportement) sous prefers-reduced-motion.
  8. Feature-flaggez. Ajoutez un kill switch ; activez par défaut seulement après tests.
  9. Mesurez. Suivez CLS et INP ; vérifiez la performance du scroll sur appareils représentatifs.

Checklist de release (à la façon SRE)

  • Hauteur de l’en-tête constante entre états (inspectez les styles calculés).
  • Le masquage utilise transform, pas des propriétés de layout.
  • Un seul écouteur de scroll pour le comportement ; il est passif et rAF-batché.
  • Les ancrages n’atterrissent pas sous l’en-tête.
  • Zone sûre gérée pour iOS.
  • Réduction de mouvement respectée.
  • Fail open : si le JS échoue, l’en-tête reste visible et utilisable.
  • Feature flag + kill switch validés en config production.
  • Dashboards RUM surveillés pour régressions CLS/INP après le déploiement.

Checklist d’ajustement (seuils et sensation)

  • Commencez avec un seuil de masquage ~10–16px et un seuil d’affichage ~4–8px.
  • Assurez-vous que « en haut » force visible et non élevé.
  • Privilégiez « montrer rapidement, cacher avec prudence ». Les utilisateurs pardonnent un en-tête qui apparaît ; ils détestent un en-tête qui les bloque.
  • Si la page a un infinite scroll, soyez plus conservateur avec le masquage. L’utilisateur défile déjà beaucoup ; n’ajoutez pas de surprises.

FAQ

1) Est-ce que cela peut se faire uniquement en CSS ?

Pas entièrement. Le CSS peut rendre quelque chose sticky, et animer un état hide/show une fois que cet état est exprimé. Mais la « direction du défilement » n’est pas une entrée CSS aujourd’hui. Si vous voulez hide-on-down/show-on-up, il vous faut du JS ou une fonctionnalité plateforme qui n’existe pas encore.

2) Dois-je utiliser position: fixed au lieu de sticky ?

Utilisez sticky à moins d’une bonne raison. Sticky participe au flux normal, ce qui évite certains cas limites de mise en page. Fixed va, mais il est plus facile de créer des chevauchements accidentels, et vous devrez gérer le padding/margins top pour éviter que le contenu soit masqué.

3) Pourquoi ne pas basculer display: none quand on cache ?

Parce que cela change la mise en page. Cela peut créer du CLS, contrarier le scroll anchoring et provoquer des reflows coûteux. Le masquage par transform garde la mise en page stable et scrolle généralement mieux.

4) will-change: transform est-il toujours bon ?

Non. Il consomme des ressources en favorisant la promotion en couche. Utilisez-le parcimonieusement, et de préférence sur l’élément que vous animez réellement (l’en-tête). Ne l’étalez pas sur toute l’UI.

5) Qu’en est-il de backdrop-filter pour un en-tête effet verre dépoli ?

Ça peut être beau et très lent. Si vous devez l’utiliser, testez sur des appareils bas de gamme et envisagez de le désactiver pendant les transitions hide/show ou derrière un test de capacité.

6) Comment empêcher l’en-tête de se cacher quand l’utilisateur défile dans un conteneur imbriqué ?

Décidez quel conteneur de scroll pilote le comportement. Si votre contenu défile à l’intérieur d’une div, utilisez les événements et mesures de cet élément plutôt que window. Les mélanges sont une source classique de comportements aléatoires.

7) Pourquoi ça se comporte différemment sur iOS Safari ?

Barres d’outils dynamiques, overscroll et différences de viewport. Évitez la logique qui dépend de positions pixel-exactes au sommet. Utilisez un sentinel pour la détection du sommet et ajoutez des seuils pour que de petites oscillations n’activent pas le toggle.

8) Comment éviter que l’en-tête se cache pendant que l’utilisateur interagit avec lui ?

Ajoutez des gardes : si l’en-tête contient l’élément actif, gardez-le visible. Optionnellement, verrouillez la visibilité pour un court instant après pointerdown/touchstart dans la zone d’en-tête. Restez simple et testez avec clavier et lecteurs d’écran.

9) Quel est le mode de défaillance le plus sûr ?

L’en-tête reste visible. Si le JS ne charge pas, plante, ou est bloqué, l’utilisateur doit toujours pouvoir naviguer. C’est pourquoi le CSS-first est important.

10) Comment mesurer le succès au-delà du « ça a l’air fluide » ?

Suivez CLS et INP, ainsi que des signaux d’engagement utilisateur comme l’utilisation de la nav (avec prudence, sans logger sur le scroll). Si le CLS augmente après le déploiement, considérez que l’en-tête y a contribué jusqu’à preuve du contraire.

Conclusion : prochaines étapes exécutables

Construisez l’en-tête sticky comme un service fiable : commencez par une base stable, ajoutez un comportement contrôlé derrière une petite machine d’état, et gardez un kill switch à portée de main. Le CSS vous apporte la stabilité. Un JS minimal vous donne la chose que le CSS ne peut pas : la direction.

Prochaines étapes :

  1. Auditez votre en-tête actuel : si il change de hauteur ou de display pendant le scroll, corrigez cela d’abord.
  2. Ajoutez la marge de défilement :target (ou des scroll-margins spécifiques aux titres) pour éviter le recouvrement d’ancrage.
  3. Implémentez une détection de direction rAF-batchée et passive avec seuils ; gardez-la en dessous de 50 lignes.
  4. Ajoutez un sentinel pour l’élévation si les bizarreries mobiles vous embêtent.
  5. Déployez derrière un feature flag, surveillez CLS/INP et soyez prêt à désactiver sans redéployer.

Quand ça marche, personne ne remarque. C’est le but. Un en-tête est un outil, pas une pièce d’art de performance.


← Précédent
Réinitialisations de lien SATA sous ZFS : motif de défaillance contrôleur/câble
Suivant →
Erreurs de TTL DNS qui hantent les migrations — Comment définir le TTL comme un pro

Laisser un commentaire