En-têtes fixes sensibles à l'état

Les en-têtes fixes sont un modèle d'UI courant, mais ils doivent souvent changer d'apparence lorsqu'ils deviennent "fixes" pour maintenir la lisibilité ou économiser de l'espace. Les solutions traditionnelles nécessitaient des écouteurs de défilement JavaScript, ce qui peut entraîner des problèmes de performances. Le CSS moderne vous permet de gérer cela de manière native à l'aide des requêtes d'état de défilement.

Mise en œuvre, étape par étape

1. Créer le conteneur fixe

Vous avez besoin d'un conteneur qui servira d'élément fixe et de conteneur pour la requête d'état de défilement.

<!-- Wrap in a section to define the containment area for the sticky element -->
<div class="section">
  <div class="sticky-container">
    <div class="sticky-header">
      Section Header
    </div>
  </div>
  <div class="content">
    <!-- Content goes here -->
  </div>
</div>

2. Appliquer le CSS pour le comportement fixe et le type de conteneur

Définissez container-type: scroll-state sur l'élément position: sticky, et non sur son ancêtre à défilement. Spécifiez toujours un container-name, car ces requêtes peuvent être imbriquées et les conteneurs sans nom entreront en conflit. Vous pouvez également la combiner avec des requêtes de taille, par exemple container-type: scroll-state inline-size.

.sticky-container {
  position: sticky;
  top: 0;
  /* MANDATORY: Always specify a container name to avoid collisions if CQs are nested */
  container-type: scroll-state;
  container-name: section-header;
  z-index: 10;
}

.sticky-header {
  /* Base styles for the header */
  background: #f0f0f0;
  padding: 20px;
  transition: background-color 0.3s, box-shadow 0.3s;
}

Important : L'état de défilement est interrogé par les descendants du conteneur d'état de défilement. Les styles de la requête @container ne s'appliqueront pas à l'élément .sticky-container lui-même, mais à ses enfants (comme .sticky-header). Vous ne pouvez pas appliquer de style à l'élément conteneur lui-même avec sa propre requête d'état de défilement.

3. Cibler l'état de blocage

Utilisez la requête @container scroll-state(...) pour appliquer des styles lorsque l'en-tête est figé.

/* Apply styles when the named container is stuck at the top */
@container section-header scroll-state(stuck: top) {
  .sticky-header {
    background: #0056b3;
    color: white;
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.15);
  }
}

Conseil : Vous pouvez également utiliser des propriétés logiques telles que stuck: inset-block-start ou stuck: inset-inline-start pour mieux prendre en charge l'internationalisation (i18n) et les mises en page de droite à gauche (RTL) en interrogeant les bords relatifs au flux plutôt que les bords physiques.

Remarques sur les modifications apportées aux dimensions

Si l'ancrage du défilement n'est pas désactivé, la modification des propriétés affectant la mise en page (hauteur, marge intérieure, taille de police) lorsqu'un élément est figé peut provoquer un scintillement visuel. L'ancrage du défilement sur le contenu du flux sous l'élément fixe ajuste le décalage de défilement pour compenser la modification de la mise en page, ce qui repousse l'élément hors de sa position fixe et déclenche une oscillation.

Désactivez l'ancrage du défilement sur le parent de l'élément fixe pour éviter ce problème :

.section {
  overflow-anchor: none;
}

Appliquez overflow-anchor: none au parent direct de l'élément position: sticky, et non à :root ou body, ce qui désactiverait l'ancrage du défilement pour l'ensemble de la page. Une fois cette configuration en place, vous pouvez modifier librement n'importe quelle propriété de l'état figé, y compris les propriétés du modèle de boîte et les transformations.

À NE PAS FAIRE : Ne vous fiez pas à overflow-anchor: none pour les éléments collés en bas. L'ancrage du défilement ne compense que les décalages de mise en page au-dessus de la position de défilement actuelle. Il n'a donc aucun effet sur les éléments bloqués en bas. Évitez de modifier les propriétés du modèle de boîte dans l'état "bloqué" pour les en-têtes bloqués en bas.

Stratégies de remplacement

Les requêtes d'état de défilement sont une amélioration progressive. Dans les navigateurs qui ne sont pas compatibles avec container-type: scroll-state, l'en-tête reste fixé en haut (en raison de position: sticky), mais il ne se transforme pas visuellement. Pour la plupart des cas d'utilisation, il s'agit de l'approche recommandée.

Si la persistance n'est pas essentielle, mais que position: sticky sans style différent serait une expérience pire que l'absence de persistance, vous pouvez également encapsuler position: sticky et la déclaration associée dans une requête @supports :

.sticky-container {
  @supports (container-type: scroll-state) {
      position: sticky;
      top: 0;
      container-type: scroll-state;
      container-name: section-header;
      z-index: 10;
    }
}

Conseil : Si votre style "bloqué" nécessite une couleur d'arrière-plan différente pour être lisible, envisagez de configurer votre mise en page de sorte que le style par défaut fonctionne partout (amélioration progressive). Si vous devez utiliser une solution de remplacement, vous pouvez limiter le comportement de position: sticky dans une requête @supports (container-type: scroll-state).

Si la transformation visuelle est absolument essentielle à la conception (par exemple, l'état figé introduit un arrière-plan ou une compression sans lesquels le contenu est illisible), vous pouvez implémenter une solution de remplacement JavaScript robuste à l'aide de IntersectionObserver. Vous devez dupliquer vos styles CSS sous une classe .is-stuck. Le polyfill générique suivant vérifie getComputedStyle pour remonter et trouver le conteneur de défilement approprié (par défaut, la fenêtre d'affichage), correspondant au comportement de @container scroll-state(stuck: top) :

function getScrollParent(node) {
  if (node == null || node === document.body || node === document.documentElement) {
    return null; // default to viewport
  }
  if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
    const overflow = getComputedStyle(node).overflow;
    if (overflow !== 'visible' && overflow !== 'clip') {
      return node;
    }
  }
  return getScrollParent(node.parentNode);
}

document.querySelectorAll('.sticky-container').forEach(container => {
  const root = getScrollParent(container);
  
  const topOffset = parseFloat(getComputedStyle(container).top) || 0;
  
  const observer = new IntersectionObserver(
    ([e]) => {
      // Toggle the fallback class on the sticky header container
      e.target.classList.toggle('is-stuck', e.intersectionRatio < 1);
    },
    { 
      root: root,
      threshold: [1],
      rootMargin: `-${topOffset + 1}px 0px 0px 0px` 
    }
  );
  
  observer.observe(container);
});

Remarque : Ce modèle IntersectionObserver générique peut également être utilisé comme polyfill pour la requête scroll-state(scrollable).