Zum Inhalt springen
Animation

Scroll-Animationen ohne JavaScript

Wie animation-timeline eine CSS-Animation an die Scrollposition koppelt, wann scroll() und wann view() richtig ist, wie das in Tailwind aussieht und was du für Firefox vorsiehst.

10 Min. LesezeitStand: 07. September 2026
ScrollCSSAnimationPerformance
Keyframes01
Serie 04
Licht, das den ganzen Tag bleibt
Sechs Leuchten aus einer Werkstatt in Utrecht.
Serie ansehenShowroom

Eine Leiste, die beim Scrollen aufzieht, oder ein Abschnitt, der beim Erscheinen einblendet: Bis vor kurzem brauchte beides JavaScript, das bei jedem Scroll-Ereignis nachrechnet. CSS kann das inzwischen selbst. Statt einer Uhr bekommt die Animation eine Zeitleiste, die an der Scrollposition hängt. Dieser Leitfaden zeigt die beiden Zeitleisten, wie du den Bereich zuschneidest, wie das in Tailwind aussieht und wie du damit umgehst, dass Firefox noch fehlt.

Das Wichtigste in Kürze
  • animation-timeline tauscht die Uhr einer CSS-Animation gegen die Scrollposition. Die Keyframes bleiben, nur der Fortschritt kommt woanders her.
  • scroll() misst einen Scrollbereich, view() den Weg eines Elements durch das Bild. Für das Einblenden ist fast immer view() gemeint.
  • animation-range schneidet aus diesem Weg das Stück heraus, auf dem die Animation läuft. Ohne die Angabe läuft sie über die ganze Strecke.
  • Firefox fehlt noch. Mit @supports bleibt der fertige Zustand überall stehen, und die Bewegung gibt es dort, wo sie funktioniert.
01

Warum der Scroll-Listener das falsche Werkzeug ist

Der klassische Weg ist ein Listener: Man hängt sich an das scroll-Ereignis, liest bei jedem Aufruf die Position aus und rechnet daraus einen Wert. Das funktioniert, aber es passiert an der empfindlichsten Stelle, die der Browser hat, nämlich im Hauptthread. Dort wird auch alles andere erledigt: Klicks beantworten, Layout rechnen, zeichnen.

Dazu kommt eine Falle, die man leicht übersieht. Wer im Listener eine Größe abfragt, etwa über getBoundingClientRect, zwingt den Browser dazu, das Layout sofort neu zu berechnen. Bei sehr vielen Aufrufen pro Sekunde wird daraus ein Ruckeln, das sich nicht mehr wegoptimieren lässt, weil die Arbeit selbst das Problem ist.

IntersectionObserver nimmt einen Teil der Last ab, beantwortet aber nur eine Ja-Nein-Frage: Ist das Element im Bild? Für ein einfaches Einblenden reicht das. Für alles, was sich weich mit dem Scrollen verändert, fehlt der Fortschritt dazwischen. Genau diese Lücke schließen Scroll-Zeitleisten in CSS.

Eine Animation, die am Scrollen hängt, braucht keinen Wert pro Bild. Sie braucht eine Zeitleiste.

02

Zwei Zeitleisten statt einer Uhr

Eine CSS-Animation läuft normalerweise auf einer Uhr: Sie dauert 400 Millisekunden, und der Fortschritt ergibt sich aus der verstrichenen Zeit. Die Eigenschaft animation-timeline tauscht diese Uhr gegen etwas anderes aus. Die Keyframes bleiben unverändert, nur die Frage nach dem Fortschritt wird anders beantwortet.

scroll() liefert den Fortschritt eines Scrollbereichs. Null Prozent ist ganz oben, hundert Prozent ganz unten. Das ist die Zeitleiste für alles, was den Stand der ganzen Seite abbildet, etwa eine Leseleiste oder eine Markierung am Rand.

view() liefert den Fortschritt eines einzelnen Elements durch den sichtbaren Bereich. Null Prozent ist der Moment, in dem es unten hereinkommt, hundert Prozent der Moment, in dem es oben verschwindet. Das ist die Zeitleiste für alles, was an einem Abschnitt hängt: einblenden, verschieben, aufziehen.

initial
opacity 0, y 12
animate
opacity 1, y 0
duration
0.5 s
ease
cubic-bezier(.22, 1, .36, 1)
stagger
80 ms

Zwölf Pixel Weg und eine halbe Sekunde. Mehr braucht ein Hero nicht.

Dieselben Keyframes, zwei Fragen: der Stand der Seite oder der Weg eines Elements durch das Bild.

@keyframes aufziehen {  from { transform: scaleX(0); }  to   { transform: scaleX(1); }} .leiste {  animation: aufziehen linear both;  animation-timeline: scroll();} .karte {  animation: aufziehen linear both;  animation-timeline: view();}
Die Keyframes sind identisch. Nur die letzte Zeile entscheidet.

scroll() fragt den Bereich, in dem gescrollt wird. view() fragt das Element, das durchs Bild wandert.

03

Die Leseleiste in acht Zeilen

Die Leseleiste ist das kleinste sinnvolle Beispiel: ein feiner Balken am oberen Rand, der mit dem Scrollen aufzieht. Wichtig ist, nicht die Breite zu animieren, sondern transform mit scaleX. Breite bedeutet Layout, scaleX bedeutet nur zeichnen, und diese Arbeit kann der Browser aus dem Hauptthread heraushalten.

Die Angabe transform-origin: left sorgt dafür, dass der Balken links verankert bleibt und nach rechts wächst, statt aus der Mitte heraus in beide Richtungen zu laufen. Ohne sie sieht die Leiste aus wie ein Regler, nicht wie ein Fortschritt.

Zwei Wörter in der Kurzform sind dabei entscheidend. linear hält den Fortschritt fest an die Scrollposition gebunden, denn eine Beschleunigungskurve über die ganze Seite fühlt sich an, als würde der Balken der eigenen Geste hinterherhinken. both sorgt dafür, dass der Balken vor und nach dem Bereich stehen bleibt, statt zurückzuspringen.

.fortschritt {  position: fixed;  inset-block-start: 0;  block-size: 3px;  background: var(--akzent);  transform-origin: left;  animation: aufziehen linear both;  animation-timeline: scroll(root block);}
Eine Leseleiste, die an der Scrollposition der Seite hängt.
Die Dauer zählt hier nicht mehrSobald animation-timeline auf eine Scroll-Zeitleiste zeigt, wird eine Dauer in Sekunden ignoriert. Der Fortschritt kommt aus der Scrollposition, nicht aus der Uhr. Eine Dauer im Code stört nicht, sie tut nur nichts, und man sucht später an der falschen Stelle nach dem Regler.

Drei Kurven decken den Alltag ab.

Eine Seite mit drei Kurven wirkt geordnet, eine mit zwölf wirkt zufällig. Diese drei reichen fast immer.

Weich0.22, 1, 0.36, 1Alles, was hereinkommt
Symmetrisch0.4, 0, 0.2, 1Auf und zu, hin und zurück
Ruhig0.32, 0.72, 0, 1Lange Wege über die Seite
04

Einblenden, wenn ein Abschnitt kommt

Für das Einblenden ist view() die richtige Zeitleiste, und dazu kommt eine zweite Angabe: animation-range. Ohne sie läuft die Animation über den gesamten Weg des Elements durch das Bild, vom ersten Pixel unten bis zum letzten oben. Das ist fast nie gemeint, denn dann ist der Abschnitt erst fertig eingeblendet, wenn er schon wieder verschwindet.

Die Abschnitte dieses Weges haben Namen. entry ist das Hereinkommen von unten, exit das Hinausgehen nach oben, cover der ganze Weg von der ersten bis zur letzten Berührung, contain die Strecke, auf der das Element vollständig im Bild ist. Mit Prozentwerten schneidest du daraus ein Stück heraus.

animation-range: entry 0% entry 60% heißt also: anfangen, sobald die Oberkante hereinkommt, fertig sein, wenn das Element zu sechzig Prozent hereingekommen ist. Das ergibt ein Einblenden, das abgeschlossen ist, bevor jemand den Abschnitt zu lesen beginnt. Genau das will man, denn Text, der sich beim Lesen noch bewegt, ist ärgerlich.

@keyframes einblenden {  from { opacity: 0; transform: translateY(1.5rem); }  to   { opacity: 1; transform: translateY(0); }} .abschnitt {  animation: einblenden linear both;  animation-timeline: view();  animation-range: entry 0% entry 60%;}
Einblenden nach oben, fertig bevor der Abschnitt in der Mitte steht.
Ohne both fällt es wieder zurückFehlt both in der Kurzform, gilt der Endzustand nur innerhalb des Bereichs. Sobald weiter gescrollt ist, greift wieder der Wert aus dem Stylesheet, und der Abschnitt wird erneut durchsichtig. Ein Wort in der Kurzform verhindert eine halbe Stunde Fehlersuche.
05

In Tailwind schreiben

Tailwind v4 bringt für Scroll-Zeitleisten keine eigenen Klassen mit. Das ist kein Hindernis, denn beide Teile, die man dafür braucht, sind vorhanden: eigene Animationen über das Theme und beliebige Eigenschaften in eckigen Klammern.

Keyframes und Name gehören in die CSS-Datei. In v4 macht eine Variable unter --animate- daraus ein Utility: aus --animate-einblenden wird die Klasse animate-einblenden, und die Kurzform mit linear und both steckt direkt im Wert. Damit steht die Animation bereit, ohne dass im Markup etwas davon zu sehen ist.

Die Zeitleiste kommt als beliebige Eigenschaft dazu. Leerzeichen schreibt man in eckigen Klammern als Unterstrich, sonst bricht die Klasse mitten im Wert. Aus animation-range: entry 0% entry 60% wird deshalb [animation-range:entry_0%_entry_60%].

/* app.css */@theme {  --animate-einblenden: einblenden linear both;} @keyframes einblenden {  from { opacity: 0; transform: translateY(1.5rem); }  to   { opacity: 1; transform: translateY(0); }} /* im Markup */  <section className="animate-einblenden [animation-timeline:view()]    [animation-range:entry_0%_entry_60%]">
Einmal im Stylesheet, danach drei Klassen im Markup.
Ab der dritten Stelle lohnt eine eigene KlasseWenn dieselbe Kombination aus Animation, Zeitleiste und Bereich an vielen Stellen steht, schreib sie einmal als Klasse ins CSS. Im Markup bleibt dann ein Wort übrig, und wenn der Bereich später doch 50 Prozent sein soll, änderst du eine Zeile statt zwanzig.
06

Wo es zuverlässig klemmt

Die Mechanik ist überschaubar, aber sie hat ein paar Stellen, an denen man zuverlässig hängenbleibt. Meist liegt es nicht an der Animation selbst, sondern daran, welchen Bereich der Browser gerade meint.

Führt in die Irre
  • scroll() ohne Angabe schreiben, während irgendein Vorfahre overflow: hidden oder auto trägt. Der Browser nimmt den nächsten Scrollbereich, und das ist dann nicht die Seite.
  • Eine benannte Zeitleiste an einem Element setzen, das kein Vorfahre des animierten Elements ist. Der Name ist dort schlicht unbekannt.
  • width, height oder top animieren. Jeder Frame löst Layout aus, und der Vorteil gegenüber dem Listener ist wieder verspielt.
  • Die Standardkurve ease stehen lassen. Über eine ganze Seite gestreckt wirkt sie wie eine hakende Bewegung.
Trägt
  • scroll(root block) schreiben, wenn wirklich die Seite gemeint ist. Ein Wort mehr, dafür ohne Zweifel.
  • Den Namen am gemeinsamen Vorfahren setzen oder ihn mit timeline-scope so weit nach oben heben, dass beide Seiten ihn sehen.
  • transform und opacity animieren. Beides läuft ohne Layout und bleibt auch bei schnellem Scrollen ruhig.
  • linear setzen und die Kurve, wenn überhaupt, über zusätzliche Keyframes formen.
Der nächste Scrollbereich ist nicht immer die SeiteJedes Element mit overflow: auto, scroll oder hidden ist ein eigener Scrollbereich. Liegt ein solcher Wrapper zwischen deinem Element und der Seite, misst scroll() ihn statt der Seite, und die Leiste bewegt sich nie. Das ist der häufigste Grund, warum eine Fortschrittsleiste einfach stehen bleibt.
07

Browserstand, Rückfallebene und ruhige Bewegung

Ein Punkt gehört offen gesagt: Diese Technik ist noch nicht überall angekommen. Chrome und Edge haben sie seit Mitte 2023, Safari seit Version 26 im Herbst 2025. Firefox hat die Umsetzung zum Stand dieses Beitrags noch nicht standardmäßig aktiv.

Das ist kein Grund, sie zu meiden, sondern einer, sie als Zugabe zu bauen. Und es ist zugleich die Antwort auf die Frage, wann sich der Umstieg von framer-motion überhaupt lohnt: bei allem, dessen Ausbleiben niemandem auffällt.

  1. Fortschritt und Leseleisten. Reine CSS-Sache. Der Balken steht in Firefox eben auf null, und niemand vermisst etwas, das er nie gesehen hat.
  2. Abschnitte, die beim Erscheinen einblenden. Ebenfalls CSS, aber mit @supports gebaut, damit der Text ohne Zeitleiste sofort sichtbar ist statt dauerhaft durchsichtig.
  3. Bewegung, die auf Zustand reagiert. Ein Menü, das aufgeht, eine Karte, die auf Daten wartet: Das hängt an React und nicht am Scrollen. Dafür bleibt framer-motion das richtige Werkzeug.
  4. Verzahnte Abläufe über mehrere Elemente. Sobald mehrere Teile in genauer Reihenfolge nacheinander loslaufen sollen, ist eine Orchestrierung in JavaScript einfacher zu lesen als eine Sammlung benannter Zeitleisten.

Für die ersten beiden Fälle sieht die Rückfallebene immer gleich aus. Der lesbare Zustand steht im normalen CSS, und nur innerhalb von @supports wird daraus eine Bewegung. Dazu kommt die Abfrage, die bei jeder Animation dazugehört: Wer prefers-reduced-motion auf reduce gestellt hat, will keine Bewegung beim Scrollen, und diese hier ist besonders unangenehm, weil sie unmittelbar an der eigenen Geste hängt.

@media (prefers-reduced-motion: no-preference) {  @supports (animation-timeline: view()) {    .abschnitt {      opacity: 0;      animation: einblenden linear both;      animation-timeline: view();      animation-range: entry 0% entry 60%;    }  }}
Sichtbar bleibt es immer. Bewegung gibt es nur, wo sie gewollt und möglich ist.

Die Bewegung ist die Zugabe, nicht die Bedingung. Lesbar muss die Seite auch ohne sie sein.

Teilen
Geschrieben von
Amelie RoesmannCreative DirectionLeonie RoesmannDesign Engineering

Wir bauen Websites und Web-Apps bei Systra Studios in Münster und legen die Bausteine daraus hier ab. Was in den Beiträgen steht, kommt aus echten Projekten, nicht aus einem Werbeprospekt.

Systra Studios: Webdesign aus Münster

Du kennst die Kurven.
Die Werte musst du nicht raten.

Eine Sammlung mit Vorschau: Kurve ansehen, laufen lassen, Wert kopieren. Für CSS und für framer-motion derselbe Wert.

  • Jede Kurve läuft zum Anschauen, bevor du sie nimmst.
  • Ein Klick kopiert den cubic-bezier-Wert.
  • Dieselben Werte stecken in allen Komponenten.
Easings ansehen
Weich0.22, 1, 0.36, 1
Symmetrisch0.4, 0, 0.2, 1
Ruhig0.32, 0.72, 0, 1
Ohne Easing0, 0, 1, 1
FAQ

Häufige Fragen

Für die Animation selbst nicht. Zeitleiste, Bereich und Keyframes stehen vollständig im CSS, und der Browser rechnet den Fortschritt aus der Scrollposition selbst aus. JavaScript kommt nur dann wieder ins Spiel, wenn du für Firefox mehr willst als den ruhigen Endzustand, also eine echte Nachbildung des Effekts. In den meisten Fällen lohnt sich dieser Aufwand nicht.

Deine Frage war nicht dabei? Wir helfen gern weiter.

Kontakt aufnehmen