CSS und LayoutsAnimationenBarrierefreiheit

Scroll-Animationen: animation-timeline statt IntersectionObserver

Scroll-Animationen: animation-timeline statt IntersectionObserver

Der klassische Reveal-on-Scroll geht so: Ein IntersectionObserver erstellen, eine Liste von Elementen beobachten, und wenn eines die Schwelle überschreitet, eine .is-visible-Klasse hinzufügen, die einen CSS-Übergang auslöst. Der Fortschrittsbalken ist schlimmer: Ein scroll-Listener berechnet scrollTop / (scrollHeight - clientHeight) und schreibt es in style.width. Beide funktionieren. Beide haben den Fehler, über den der INP-Artikel sprach – die Logik lebt auf dem Hauptthread.

Aber es gibt ein zweites Problem mit der JavaScript-Version, und dieses ist das interessantere, weil keine Optimierung es behebt.

Warum JS-Parallax immer leicht falsch aussieht

Moderne Browser scrollen auf dem Compositor. Wenn man eine Seite aufwischt, wartet das Scrollen selbst nicht auf JavaScript – der Compositor bewegt den bereits gerenderten Inhalt und zeigt Frames, schnell, unabhängig davon, womit der Hauptthread beschäftigt ist. Deshalb scrollt eine Seite mit schwerem JS trotzdem flüssig, selbst wenn alles andere ruckelt.

Das scroll-Ereignis wird jedoch nach dem bereits Geschehenen an das JavaScript geliefert. Die Sequenz ist also: Browser scrollt und rendert, dann informiert er den Listener über die neue Position, und erst dann kann der Code die Parallax-Ebene verschieben. Die Ebene reagiert immer auf eine Scroll-Position, die der Benutzer bereits gesehen hat. Das liest sich bei Geschwindigkeit als subtile Ablösung – der Hintergrund, der einen halben Takt hinter dem Vordergrund gleitet.

Scroll-driven Animations schneiden das Problem an der Wurzel. Es ist dieselbe CSS-Animation, die man bereits kennt, mit einer Substitution: Anstatt dass Zeit den Fortschritt von 0% auf 100% antreibt, tut es die Position eines Scroll-Containers. Halb runterscrollen, und die Animation ist halb durch. Zurückscrollen, und sie läuft rückwärts. Der Browser kann sie auf dem Compositor ausführen.

Ein Fortschrittsbalken in vier Zeilen

Der erste Timeline ist scroll(), der die Position eines Scroll-Containers verfolgt – standardmäßig den nächsten scrollenden Vorfahren. Ein Lesefortschrittsbalken ist eine scaleX-Animation von 0 bis 1:

css

.progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root block);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}

Drei bewusste Entscheidungen hier. linear, weil jede andere Easing-Funktion den Balken aus der Synchronisation mit der tatsächlichen Leseposition bringen würde. Keine Dauer, weil Dauer bei einem Scroll-Timeline keine Bedeutung hat. Und scroll(root block) statt einem nackten scroll(), um den Scroller explizit zu benennen.

Reveal on Scroll: der view()-Timeline

Der zweite Timeline, view(), verfolgt etwas anderes: Wie weit ein bestimmtes Element durch das Scrollfenster gereist ist:

css

.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}

animation-range ist die Eigenschaft, die das nutzbar macht. Vier benannte Bereiche sind es wert, auswendig zu lernen:

  • entry – vom ersten Pixel des Elements bis zu seinem vollständigen Eintreten. Für alles, was ankommen soll.
  • exit – vom Beginn des Verlassens bis zum vollständigen Verschwinden.
  • cover – die gesamte Reise des Elements. Der Standard, deshalb endet eine Animation ohne animation-range erst, wenn das Element den Bildschirm verlässt.
  • contain – nur der Bereich, in dem das Element vollständig sichtbar ist.

Der Fallstrick, der eine Stunde kostet: Das animation-Kürzel setzt den Timeline zurück

Diese ist wirklich überraschend, scheitert lautlos und erwischt fast jeden einmal.

animation ist ein Kürzel, und wie jedes CSS-Kürzel setzt es alle seine Langhand-Komponenten auf ihre Anfangswerte zurück – einschließlich derer, die man nicht getippt hat. animation-timeline und animation-range gehören zu dieser Familie. Also macht das Folgende absolut gar nichts:

css

/* KAPUTT: das Kürzel setzt animation-timeline zurück auf auto */
.reveal {
animation-timeline: view();
animation-range: entry 0% entry 60%;
animation: fade-up linear both;
}

Die Animation läuft – auf dem Standard-zeitbasierten Timeline, sofort, einmal, beim Laden der Seite. Die Korrektur ist nur eine Frage der Reihenfolge:

css

/* Korrekt: Timeline und Range nach dem Kürzel */
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}

Dieselben Eigenschaften, dieselben Werte, andere Reihenfolge, völlig anderes Verhalten. Die Regel: Bei einer scroll-gesteuerten Animation sind animation-timeline und animation-range immer die letzten zwei Deklarationen im Block.

Benannte Timelines: Ein Ding basierend auf einem anderen animieren

Wenn man ein Element animieren möchte, das sich auf dem Scroll eines anderen Elements basiert, gibt man dem Timeline einen Namen:

css

.gallery {
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.gallery-indicator {
animation: fill linear both;
animation-timeline: --gallery;
timeline-scope: --gallery;
}

timeline-scope hebt den Namen hoch genug im Baum, damit Geschwister und entfernte Verwandte ihn sehen können.

Progressive Enhancement ist hier nicht optional

Die Unterstützung kam zuerst in Chromium, Safari folgte, und Firefox ist am langsamsten – Can I Use prüfen, bevor man entscheidet, wie viel man darauf aufbaut.

css

.reveal {
opacity: 1; /* Standard: überall sichtbar */
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
}

Fallstricke und Best Practices

  • Nur transform und opacity animieren. Nur diese bleiben auf dem Compositor.
  • Niemals Inhalt verstecken, den nur eine Animation enthüllen kann. Der Basiszustand muss der sichtbare sein.
  • „Warum steht meine Animation still?" ist fast immer der Scroll-Container. overflow: hidden auf einem Wrapper erzeugt einen Scroll-Container, der nie scrollt.
  • both als Fill-Mode verwenden. Ohne es rendert das Element seinen Basiszustand, bis der Bereich beginnt.
  • Reduced Motion ist eine medizinische Einstellung, keine Höflichkeit.
  • Kein JavaScript löschen, das noch gebraucht wird.

Fazit

Die Artikel über INP und scroll-gesteuerte Animationen teilen eine Intuition: Den Hauptthread nicht dazu bringen, Arbeit zu erledigen, die anderswo oder früher erledigt werden könnte. Scroll-driven Animations wenden das auf eine ganze Kategorie von Effekten an.