Zustandsabhängige Sticky Header

Fixierte Header sind ein gängiges UI-Muster, aber sie müssen oft ihr Aussehen ändern, wenn sie „fixiert“ werden, um die Lesbarkeit zu erhalten oder Platz zu sparen. Bei herkömmlichen Lösungen waren JavaScript-Scroll-Listener erforderlich, was zu Leistungsproblemen führen kann. Mit modernem CSS können Sie dies nativ mit Scrollstatusabfragen erledigen.

Implementierungsschritte

1. Angepinnten Container erstellen

Sie benötigen einen Container, der als fixiertes Element und als Container für die Abfrage des Scrollstatus dient.

<!-- 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 für fixiertes Verhalten und Containertyp anwenden

Legen Sie container-type: scroll-state für das position: sticky-Element fest, nicht für das scrollbare übergeordnete Element. Geben Sie immer einen container-name an, da diese Abfragen verschachtelt sein können und unbenannte Container kollidieren. Sie können ihn auch mit Größenabfragen kombinieren, z.B. 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;
}

Wichtig: Der Scrollstatus wird von untergeordneten Elementen des Scrollstatus-Containers abgefragt. Die Stile in der @container-Abfrage werden nicht auf das .sticky-container-Element selbst angewendet, sondern auf seine untergeordneten Elemente (z. B. .sticky-header). Sie können das Container-Element nicht mit seiner eigenen Scrollstatus-Abfrage formatieren.

3. Ziel: Blockadezustand

Verwenden Sie die Abfrage @container scroll-state(...), um Stile anzuwenden, wenn der Header fixiert ist.

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

Tipp: Sie können auch logische Attribute wie stuck: inset-block-start oder stuck: inset-inline-start verwenden, um die Internationalisierung (i18n) und Layouts mit Schreibrichtung von rechts nach links (RTL) besser zu unterstützen. Dazu fragen Sie flussbezogene statt physische Kanten ab.

Hinweise zu Dimensionsänderungen

Wenn die Scrollverankerung nicht deaktiviert ist, kann das Ändern von Eigenschaften, die sich auf das Layout auswirken (Höhe, Innenabstand, Schriftgröße), beim Fixieren zu visuellem Flackern führen. Die Scrollverankerung für den Inhalt im Fluss unter dem fixierten Element passt den Scroll-Offset an, um die Layoutänderung auszugleichen. Dadurch wird das Element wieder aus seiner fixierten Position verschoben und es kommt zu einer Schwingung.

Deaktivieren Sie die Scrollverankerung für das übergeordnete Element des fixierten Elements, um dies zu vermeiden:

.section {
  overflow-anchor: none;
}

Wende overflow-anchor: none auf das direkte übergeordnete Element des position: sticky-Elements an, nicht auf :root oder body, da dadurch die Scrollverankerung für die gesamte Seite deaktiviert würde. Anschließend können Sie jede Eigenschaft im fixierten Zustand frei ändern, einschließlich Box-Modell-Eigenschaften und Transformationen.

NICHT EMPFOHLEN: overflow-anchor: none für Elemente verwenden, die am unteren Rand fixiert sind. Die Scrollverankerung gleicht nur Layoutverschiebungen oberhalb der aktuellen Scrollposition aus und hat daher keine Auswirkungen auf Elemente, die am unteren Rand fixiert sind. Vermeiden Sie es, die Box-Modell-Eigenschaften im fixierten Zustand für unten fixierte Header zu ändern.

Fallback-Strategien

Scrollstatusabfragen sind eine progressive Verbesserung. In Browsern, die container-type: scroll-state nicht unterstützen, bleibt der Header aufgrund von position: sticky oben fixiert, wird aber nicht visuell transformiert. Für die meisten Anwendungsfälle ist dies der empfohlene Ansatz.

Wenn die Fixierung nicht unbedingt erforderlich ist, aber position: sticky ohne unterschiedliches Styling schlechter wäre als gar keine Fixierung, kannst du position: sticky und die zugehörige Deklaration auch in eine @supports-Abfrage einbinden:

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

Tipp: Wenn für die Lesbarkeit des „fixierten“ Stils eine andere Hintergrundfarbe erforderlich ist, sollten Sie Ihr Layout so einrichten, dass der Standardstil überall funktioniert (Progressive Verbesserung). Wenn du einen Fallback verwenden musst, kannst du das position: sticky-Verhalten selbst in einer @supports (container-type: scroll-state)-Abfrage einschränken.

Wenn die visuelle Transformation für das Design absolut entscheidend ist (z.B. wenn der fixierte Zustand einen Hintergrund oder eine Komprimierung einführt, ohne die der Inhalt unlesbar ist), kannst du mit IntersectionObserver einen robusten JavaScript-Fallback implementieren. Sie müssen Ihre CSS-Stile unter einer .is-stuck-Klasse duplizieren. Mit dem folgenden generischen Polyfill wird getComputedStyle geprüft, um den richtigen Scrollcontainer zu finden (standardmäßig der Darstellungsbereich). Das entspricht dem Verhalten von @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);
});

Hinweis: Dieses allgemeine IntersectionObserver-Muster kann auch als Polyfill für die scroll-state(scrollable)-Abfrage verwendet werden.