Magische Effekte
– jetzt in CSS.
Drei neue native Browser-APIs, ein gemeinsames Versprechen: komplexe Animationseffekte nativ — ohne Framer Motion, GSAP oder umständliches JS.
Dieses Deck nutzt Übergänge per View-Transitions-API · Scroll-Demos laufen live · Pfeiltasten zum Blättern
View
Transitions.
Snapshot alt, DOM-Update, Snapshot neu — der Browser animiert den Diff. Erst Cross-Document (CSS only), dann Same-Document (JS/LiveView).
Snapshot alt → DOM-Update → Snapshot neu
Der Browser fotografiert die Seite vorher und nachher, animiert den Diff. Default: Crossfade.
- Cross-Document (MPA) — pure CSS, zwei echte HTML-Dokumente
- Same-Document (SPA/LiveView) — JS:
startViewTransition()wrappt die Mutation
Cross-Document — CSS only
Same-Origin-Navigation. Kein JS nötig. Eine Zeile CSS auf beiden Seiten — Default: Crossfade.
@view-transition { navigation: auto; }
Same-Document – JS API
Dieselbe API, verschiedene Effekte: Default-Crossfade — oder Masken-Reveal via clipPath auf dem ::view-transition-new(root)-Pseudo.
Shared-Element-Morph
Setz view-transition-name statisch aufs Element. Jede Layout-Änderung wird zur flüssigen Interpolation — Position, Größe, Border-Radius. Kein JS-Umschieben von Namen.
/* Eine Zeile am Container reicht */
.card { view-transition-name: card; }
// JS: ein Klick, ein Class-Toggle
card.onclick = () => document.startViewTransition(
() => card.classList.toggle("open")
);
Und beim Karten-Grid?
Namen müssen pro Dokument eindeutig sein. Bei 20 Karten also nicht 20-mal derselbe Name. Zwei Wege:
/* 1) Eindeutiger Name je Karte (per data-attr in JS oder inline var) */
.card[data-id="42"]{ view-transition-name: card-42; }
/* 2) view-transition-class: eine Klasse deckt viele Snapshots ab */
.card { view-transition-class: card; }
.card.active{ view-transition-name: card-active; } /* aktiv hat einzigartigen Namen */
/* Regeln nun via Klasse ansprechbar */
::view-transition-group(.card){
animation-timing-function: cubic-bezier(.7,0,.2,1);
}
Same-Document — Phoenix LiveView
Seit LiveView 1.1.18: onDocumentPatch im dom-Objekt. Der eigentliche Patch wird gewrappt — kein applyDiff-Monkeypatching mehr.
const liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
dom: {
onDocumentPatch(apply){ // apply() = der eigentliche Patch
if(!document.startViewTransition) return apply();
document.startViewTransition(() => apply());
}
}
});
view-transition-name vor dem Patch setzen (im mounted()-Hook oder statisch im Markup), nicht nachträglich per Patch.Selektiv animieren — nur wenn's zählt
Ohne Filter flackert jeder Presence-, Timer- oder Form-Update. Server entscheidet per Event, wann.
let animateNext = false;
window.addEventListener("phx:view-transition", () => { animateNext = true; });
dom: {
onDocumentPatch(apply){
if(animateNext && document.startViewTransition){
animateNext = false;
document.startViewTransition(() => apply());
} else { apply(); }
}
}
{:noreply, push_event(socket, "view-transition", %{})} # nur wenn animieren
Was schiefgeht — und wie nicht
view-transition-namepro Dokument eindeutig und auf beiden Seiten vorhanden- Name vor dem Snapshot setzen, nicht nachträglich; jeder Name = eigener Snapshot-Layer
- Kein
display:contentsauf animierten Elementen — nimmt sie aus dem Box-Tree - Callback schlank halten — er blockiert INP
prefers-reduced-motionrespektieren
Cross-doc-Haken zur Abgrenzung: pageswap (alte Seite) / pagereveal (neue Seite).
Browser Support
Details bei caniuse: view-transitions · cross-document-view-transitions
Scroll-driven
Animations.
Animation per Scroll-Position steuern. animation-timeline: scroll()
oder view() koppelt @keyframes an Container-Scroll
bzw. Element-Sichtbarkeit — CSS statt JS Scroll-Listener.
animation-timeline statt Zeit
Alles was du über @keyframes, animation-timing-function und fill-mode weißt, gilt weiter. Nur die Uhr ändert sich: statt Sekunden — Scroll-Position.
@keyframes reveal{ from{opacity:0} to{opacity:1} }
.item{
animation: reveal 1ms linear both; /* 1ms wegen Firefox-Quirk */
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
Zwei Timeline-Typen: scroll() · view()
scroll()— Position eines Scroll-Containers (0% oben, 100% unten). Für globale Effekte wie Progress-Bars, Parallax-Layer.view()— Sichtbarkeit eines Elements im Scrollport. Für „reveal on enter", Media-Enlarge.
.progress { animation-timeline: scroll(root); }
.reveal { animation-timeline: view(); }
.local { animation-timeline: scroll(nearest block); }
animation-range — wann startet, wann endet's
cover— Element schneidet Viewport an, verlässt ihncontain— Element vollständig sichtbarentry— Weg rein in den Viewportexit— Weg raus aus dem Viewport
animation-range: entry 10% cover 40%;
/* ↑ start ↑ end
erst wenn 10% des Elements sichtbar,
fertig wenn 40% des Weges durch den Viewport zurückgelegt sind */
Benannte Timelines — timeline-scope
Anonyme Timelines sind das häufigste Muster. Benannte trennen Scope: Kind-Element hört auf Timeline des Eltern-Containers.
.gallery{
scroll-timeline-name: --gallery;
scroll-timeline-axis: x;
overflow-x: auto;
}
.gallery-progress{
animation: fill 1ms linear both;
animation-timeline: --gallery;
}
/* außerhalb: Scope hochziehen */
body{ timeline-scope: --gallery; }
Vier Muster — live
Achtung: view() sieht nur die Originalposition ohne Transform
Ein transform: translate() verschiebt nur die visuelle Darstellung — nicht die Layout-Box. Die Timeline (und damit Start/Ende der animation-range) bezieht sich auf die ursprüngliche, untransformierte Position.
transform. Und ja, das ist genau der Grund, warum eine Reveal-Animation komisch losfeuert, wenn du das Element mit translate nach unten schiebst./* ❌ Range startet wo das Element STAND, nicht wo es sichtbar ist */
.item{
transform: translateY(200px); /* verschiebt die Pixel … */
animation-timeline: view(); /* … aber nicht die Layout-Box */
}
Progressive Enhancement · Firefox-1ms-Quirk
@supports (animation-timeline: view()){
.item{
animation: reveal 1ms linear both; /* nicht 0ms — Firefox verlangt einen Wert */
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
}
@supports not (animation-timeline: view()){
.item{ opacity: 1 } /* Fallback: einfach da */
}
- 1ms statt 0ms — Firefox ignoriert bei scroll-driven zwar die Duration, verlangt aber einen Nicht-Null-Wert
fill-mode: bothfür stehenden Endzustand- Nur
transform/opacity— sonst zurück auf den Main-Thread
Browser Support
scroll()/view()Firefox: layout.css.scroll-driven-animations.enabled, in Nightly aktiv. Für Produktion: @supports-Fallback ist Pflicht — kein Effekt darf trotzdem eine nutzbare Seite ergeben.
Details bei caniuse: animation-timeline
Scroll-triggered
Animations.
Statt scrubben: triggern. Eine zeitbasierte Animation startet, sobald
ein Element in den Viewport eintritt — Ersatz für IntersectionObserver.
Aktuell nur Chrome 145+.
Scrubben vs. Triggern
Scroll-driven koppelt die Animation an die Scroll-Position (Scrub). Scroll-triggered feuert eine ganz normale zeitbasierte Animation, sobald ein Range betreten wird.
- Ersetzt IntersectionObserver für „reveal on enter"
- Vor- und Rückwärts konfigurierbar (
play-forwards·play-backwards) - Aktuell nur in Chrome 145+ — Syntax noch experimentell
Syntax: timeline-trigger · animation-trigger
.subject{
timeline-trigger: --t view() contain / cover; /* Aktivierungs- / Aktiv-Range */
}
.box{
animation: pop .35s ease both;
animation-trigger: --t play-forwards play-backwards;
}
@keyframes pop{
from{ opacity:0; transform: translateY(24px) scale(.7) }
to { opacity:1; transform: none }
}
Browser Support
animation-triggerCSS.supports("animation-trigger","--t play-forwards").Details bei caniuse: animation-trigger
Quellen & Links
- Cross-Document View Transitions, Part 1 — css-tricks.com
- Cross-Document View Transitions, Part 2 — css-tricks.com
- caniuse: view-transition-class
- An Interactive Guide to Scroll-Driven Animations — Josh W. Comeau
- Scroll-Driven Animations — juhg.hu
- Scroll-triggered Animations — developer.chrome.com