Lab

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 .beat ohne jede Animation, der Block steht von Anfang an bei opacity: 1. Es gibt keinen Sprung beim Laden, nur die statische Fassung von Beginn an.
  • prefers-reduced-motion: reduce schaltet zusätzlich ab, auch in Browsern, die view() 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.

Gebaut aus Tokens, nicht aus Stock-Fotos. Keine Cookies an Bord.