Statusbewuste vastgezette koppen

Plakkende koppen zijn een veelgebruikt UI-patroon, maar ze moeten vaak hun uiterlijk veranderen als ze 'vastgeplakt' raken om de leesbaarheid te behouden of ruimte te besparen. Traditionele oplossingen vereisten JavaScript-scroll-listeners, wat prestatieproblemen kan veroorzaken. Met moderne CSS kun je dit native afhandelen met Scroll State Queries.

Stapsgewijze implementatie

1. De sticky container maken

Je hebt een container nodig die fungeert als het vastgezette element en de container voor de query voor de scrollstatus.

<!-- 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. CSS toepassen voor vastgezet gedrag en containertype

Stel container-type: scroll-state in voor het element position: sticky, niet voor de scrollbare bovenliggende elementen. Geef altijd een container-name op, omdat deze query's genest kunnen zijn en containers zonder naam met elkaar kunnen botsen. Je kunt deze ook combineren met groottequery's, bijvoorbeeld 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;
}

Belangrijk: De scrollstatus wordt opgevraagd door afstammelingen van de scrollstatuscontainer. De stijlen in de @container-query worden niet toegepast op de .sticky-container zelf, maar op de onderliggende elementen (zoals .sticky-header). Je kunt het containerelement zelf geen stijl geven met de eigen scroll-state-query.

3. De vastgelopen status targeten

Gebruik de query @container scroll-state(...) om stijlen toe te passen als de koptekst vastzit.

/* 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);
  }
}

Tip: Je kunt ook logische eigenschappen zoals stuck: inset-block-start of stuck: inset-inline-start gebruiken om internationalisering (i18n) en indelingen van rechts naar links (RTL) beter te ondersteunen door flow-relatieve randen op te vragen in plaats van fysieke randen.

Opmerkingen over dimensiewijzigingen

Als scrollverankering niet is uitgezet, kan het wijzigen van eigenschappen die van invloed zijn op de indeling (hoogte, opvulling, lettergrootte) wanneer deze vastloopt, visueel flikkeren veroorzaken. Scrollverankering op de in-flow content onder het vastgezette element past de scrolloffset aan om de wijziging in de vormgeving te compenseren. Hierdoor wordt het element weer uit de vastgezette positie geduwd en wordt een oscillatie geactiveerd.

Zet scrollverankering uit voor het bovenliggende element van het plakkerige element om dit te voorkomen:

.section {
  overflow-anchor: none;
}

Pas overflow-anchor: none toe op het direct bovenliggende element van het element position: sticky, niet op :root of body, omdat dit scrollverankering voor de hele pagina uitzet. Als je dit hebt ingesteld, kun je elke property in de vastgezette status vrij wijzigen, waaronder boxmodel-property's en transformaties.

NIET DOEN: Vertrouw op overflow-anchor: none voor elementen die aan de onderkant zijn vastgezet. Scrollverankering compenseert alleen voor verschuivingen van de indeling boven de huidige scrollpositie, dus het heeft geen effect op elementen die onderaan vaststaan. Wijzig de eigenschappen van het boxmodel niet in de vastgezette status voor headers die onderaan zijn vastgezet.

Reservestrategieën

Query's voor de scrollstatus zijn een progressive enhancement. In browsers die container-type: scroll-state niet ondersteunen, blijft de kop bovenaan staan (vanwege position: sticky), maar wordt deze niet visueel getransformeerd. Voor de meeste use cases is dit de aanbevolen aanpak.

Als vastzetten niet essentieel is, maar position: sticky zonder andere stijl slechter is dan helemaal niet vastzetten, kun je de position: sticky en de bijbehorende declaratie ook verpakken in een @supports-query:

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

Tip: Als je vastgezette stijl een andere achtergrondkleur vereist voor de leesbaarheid, kun je de lay-out zo instellen dat de standaardstijl overal werkt (progressive enhancement). Als je een fallback moet gebruiken, kun je het position: sticky-gedrag zelf beperken binnen een @supports (container-type: scroll-state)-query.

Als de visuele transformatie absoluut essentieel is voor het ontwerp (bijvoorbeeld als de vastgezette status een achtergrond of compressie introduceert zonder welke de content onleesbaar is), kun je een robuuste JavaScript-fallback implementeren met IntersectionObserver. Dupliceer je CSS-stijlen onder een .is-stuck-klasse. De volgende algemene polyfill controleert getComputedStyle om omhoog te gaan en de juiste scrollcontainer te vinden (standaard de viewport), wat overeenkomt met het gedrag van @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);
});

Opmerking: Dit algemene IntersectionObserver-patroon kan ook worden gebruikt als polyfill voor de query scroll-state(scrollable).