2026-08-31 · css · scroll-driven-animations · astro · a11y
Scroll-getriebene Beats ohne JavaScript
Wie die Arbeitsteilungs-Erzählung auf /arbeitsweise mit animation-timeline: view() läuft, welchen Fallback sie bekommt und was ein Astro-Update dabei einmal lautlos kaputt gemacht hat.
Auf /arbeitsweise stand bis vor kurzem ein Bild für den Abschnitt „Wer macht
was”: der Blechroboter fächert Papier durch, der Nighthog liest eine Seite und
legt eine zurück. Jetzt steht dort dasselbe als Text, in drei Blöcken, die
beim Scrollen nacheinander einziehen. Die Technik dafür ist eine einzige
CSS-Eigenschaft, ganz ohne Intersection Observer oder eigenen
Scroll-Listener.
Drei Beats statt eines Bildes
Die Erzählung liegt als drei .beat-Blöcke im Markup, jeder mit eigenem
Label:
<div class="beat">
<p class="beat-label">01 · Die KI erzeugt Menge</p>
<div class="zeilen" aria-hidden="true">
<span class="zeile">scaffold: projektgerüst</span>
<span class="zeile">migrate: tabelle kunden → v2</span>
<span class="zeile">analyse: 214.331 zeilen gelesen</span>
<!-- … -->
</div>
</div>
Beat zwei zeigt dieselben Zeilen noch einmal, einige mit ✓ bestätigt,
andere durchgestrichen mit ✗ und einer kurzen Begründung. Beat drei ist ein
Terminal-Prompt mit blinkendem Cursor: Signed-off-by: eine Person, kein Werkzeug. Ohne Animation ergibt das schon eine lesbare Seite, drei
Absätze untereinander. Die Animation ist die Zugabe, kein Ersatz für Inhalt.
Wie animation-timeline: view() hier läuft
Jeder Beat bekommt eine eigene Keyframe-Animation, deren Fortschritt nicht an Zeit hängt, sondern an der Position des Elements im Scroll-Container:
@supports (animation-timeline: view()) {
.beat {
--beat-timeline: view();
animation-name: beat-in;
animation-fill-mode: both;
animation-timeline: var(--beat-timeline);
animation-range: entry 0% cover 45%;
}
@keyframes beat-in {
from { opacity: 0; transform: translateY(64px); }
to { opacity: 1; transform: none; }
}
}
view() erzeugt eine Timeline, die 0 % ist, sobald das Element den
Viewport berührt, und 100 %, sobald es ihn wieder verlässt. animation-range: entry 0% cover 45% grenzt das ein: Die Animation beginnt beim ersten Kontakt
mit dem Viewport und ist fertig, wenn das Element knapp die Hälfte seines
Wegs durch den Viewport hinter sich hat. Das ergibt bei üblichen
Fensterhöhen rund 600 Pixel Gleitweg. Scrollt man schnell, zieht der Beat
trotzdem fertig ein, er verpasst nur die Zwischenschritte.
Der Fallback
Zwei Ebenen, keine davon optional:
@supports (animation-timeline: view())umschließt die ganze Regel. Browser ohne die Eigenschaft bekommen.beatohne jede Animation, der Block steht von Anfang an beiopacity: 1. Es gibt keinen Sprung beim Laden, nur die statische Fassung von Beginn an.prefers-reduced-motion: reduceschaltet zusätzlich ab, auch in Browsern, dieview()können:
@media (prefers-reduced-motion: reduce) {
.beat { animation: none; }
.caret { animation: none; }
}
Beide Fallbacks landen auf demselben Ergebnis: drei sofort lesbare Blöcke. Progressive Enhancement in der eigentlichen Bedeutung des Worts, nicht in der Marketing-Version davon.
Drei Anläufe für einen Range
Die erste Fassung nahm animation-range: entry 5% entry 40% und startete
bei opacity: 0.2. Auf dem Papier eine sanfte Kurve, in der Praxis ein
Problem: Wer die Seite direkt öffnet, sieht Beat 1 bei bestimmten
Fensterhöhen mitten in seiner Entry-Phase, gedimmt und kaum lesbar, ohne
zurückzuscrollen.
Die zweite Fassung zog die Range auf entry 0% entry 25% zusammen und hob
den Startwert auf 0.35. Lesbar war damit alles. Nur nahm den Effekt
niemand mehr wahr: Eine Entry-Range hängt an der Höhe des Elements, und bei
Blöcken von rund 250 Pixeln war der komplette Übergang nach weniger als 100
Pixeln Scrollweg vorbei. Ein einziger Mausrad-Tick übersprang ihn. In der
Messung flippte die Opazität zwischen zwei 100-Pixel-Schritten von 0,35 auf
1, ohne einen Zwischenwert zu zeigen.
Die dritte Fassung löst beides: eine cover-Range koppelt das Ende der Animation an den Weg durch den Viewport statt an die Elementhöhe. Der Übergang dauert jetzt mehrere hundert Pixel, die Messung zeigt Zwischenwerte bei jedem Schritt, und ein direkt geladener Beat bleibt lesbar. Wahrnehmbarkeit ist ein eigenes Kriterium. Lesbarkeit hatten wir schon zweimal.
Ein Nachtrag aus dem echten Betrieb: Auch die dritte Fassung fiel beim
ersten menschlichen Test durch, mit korrekten Messwerten und ohne dass der
Betrachter etwas bemerkte. Startopazität 0,35 mit 20 Pixeln Versatz liest
sich beim normalen Scrollen als gewöhnlich nachladender Inhalt. Es brauchte
zwei Nachschärfungen bis zum heutigen Stand: Opazität 0 und 64 Pixel Versatz
über knapp die halbe Viewport-Strecke (cover 45%). Messwerte belegen, dass
etwas passiert. Ob man es sieht, sagt nur ein Mensch.
Der Bug, den kein Build-Check fand
Der interessantere Fehler kam über ein Dependency-Update. Ein npm audit fix hob Astro von 6 auf 7 und zog @astrojs/mdx sowie das
@astrojs/svelte-Integrationspaket mit hoch. Astro 7 minifiziert CSS über
Lightning CSS, und Lightning CSS zieht getrennte animation-*-Longhands
zu einer animation-Shorthand zusammen, wo das möglich ist. animation-timeline
gehört nicht zu den Werten, die in dieser Shorthand gültig sind. Das Ergebnis:
Lightning CSS baute aus animation-name, animation-fill-mode und
animation-timeline eine einzige animation: …-Deklaration mit einem
ungültigen Bestandteil, und Chrome verwarf die komplette Zeile beim Parsen,
lautlos. Weder Build noch Konsole meldeten etwas, npm run check und npm run build liefen beide sauber durch. Nur die Animation lief nicht mehr.
Der Fix greift genau an dem Punkt an, wo der Minifier zusammenfassen darf:
--beat-timeline: view();
animation-name: beat-in;
animation-fill-mode: both;
animation-timeline: var(--beat-timeline);
animation-timeline bekommt seinen Wert über eine Custom Property statt
direkt. Für Lightning CSS ist var(--beat-timeline) zur Minify-Zeit ein
opakes Token, kein bekannter Timeline-Wert, den es in eine Shorthand
einsortieren könnte. Die drei Zeilen bleiben getrennt stehen, der Browser
bekommt eine gültige, longhand geschriebene animation-timeline-Deklaration.
Verifiziert wurde das nicht im Terminal, sondern im Browser: Seite geladen,
gescrollt, beobachtet, ob die Beats tatsächlich auf opacity: 1 ziehen. Ein
Feature, das ein Minifier lautlos entfernen kann, meldet sich in keinem
Typecheck und in keinem Build-Log. Nach jedem größeren Dependency-Bump lohnt
sich deshalb ein kurzer Blick ins tatsächlich gerenderte CSS, nicht nur ins
grüne Build.
Wer die drei Beats in Aktion sehen will, statt der Codeblöcke hier zu
glauben: /arbeitsweise, Abschnitt „Wer macht was”, einfach scrollen.
