Next.jsAnimationenCSS und Layouts

View Transitions in Next.js: Seitenanimationen ohne Animationsbibliothek

View Transitions in Next.js: Seitenanimationen ohne Animationsbibliothek

Im letzten Artikel zeigte ich, dass INP die Lücke zwischen einem Klick und dem Frame misst, der ihn widerspiegelt, und dass eine einzige lange Aufgabe auf dem Hauptthread diese Lücke ruinieren kann – egal wie günstig die Animation selbst ist. Seitenübergänge sind die extreme Ausprägung dieses Problems: Sie verlangen vom Hauptthread seine teuerste Arbeit – das Rendern einer völlig neuen Seite – und gleichzeitig eine flüssige 60fps-Animation.

Es lohnt sich, genau zu sagen, worum es bei dieser Arbeit wirklich ging, denn ihr Name ist dreißig Sekunden Geschichte, die das gesamte API erklären, das sie ersetzte.

Was die Bibliotheken wirklich taten: FLIP

2015 beschrieb Paul Lewis eine Technik, die er FLIP nannte – First, Last, Invert, Play. Man kann ein Element nicht günstig von einer Layout-Position zur anderen animieren, weil top und left in jedem Frame eine Layout-Neuberechnung erzwingen. Also schummelt man. Man zeichnet die Position First auf, lässt das DOM sofort in seinen Last-Zustand springen, Invertiert diesen Sprung dann mit einem transform, der das Element so aussehen lässt, als hätte es sich nie bewegt, und spielt das Transform schließlich zurück zu null. Der Benutzer sieht ein sanftes Gleiten. Der Browser hat immer nur ein transform animiert.

Jede Shared-Layout-Animation, die man je gesehen hat – Framer Motions layoutId, das Flip-Plugin von GSAP, jedes „Magic Move" in einem Designwerkzeug – ist FLIP. Und FLIP hat strukturelle Kosten: Etwas muss das alte und das neue Element gleichzeitig am Leben erhalten, auf beiden getBoundingClientRect() aufrufen und das synchron tun, in JavaScript, mitten in einem Routenwechsel.

Die View Transitions API nimmt einem diesen Trick ab und gibt ihn an den Browser. Anstatt zwei lebende DOM-Bäume zu halten und sie zu vermessen, fotografiert der Browser den alten Zustand, lässt das DOM beliebig aktualisieren, fotografiert den neuen Zustand und animiert zwischen den zwei Bildern – auf dem Compositor, genau wie transform und opacity aus dem Mikrointeraktions-Artikel.

Die Sequenz und der Baum, den sie aufbaut

Das Herzstück des API ist ein einziger Aufruf. Wickle ein DOM-Update in document.startViewTransition() ein, und der Browser führt eine feste Sequenz aus: Rendering einfrieren, aktuelle Seite aufnehmen, Callback ausführen, Ergebnis aufnehmen, Baum von Pseudo-Elementen über dem Dokument aufbauen.

javascript

function navigate(update) {
if (!document.startViewTransition) {
update(); // kein Support: einfach aktualisieren, keine Animation
return;
}
document.startViewTransition(() => update());
}

Diesen Pseudo-Element-Baum sollte man namentlich kennen, weil das den Unterschied macht zwischen sicherem Anpassen eines Übergangs und dem Raten bei Selektoren:

css

::view-transition /* die Überlagerung, die die ganze Seite bedeckt */
::view-transition-group(name) /* animiert Größe und Position */
::view-transition-image-pair(name)
::view-transition-old(name) /* der "Vorher"-Schnappschuss */
::view-transition-new(name) /* der "Nachher"-Schnappschuss */

Die Aufteilung ist wichtig. Die group ist das, was sich bewegt und die Größe ändert – also gehört animation-duration dort hin. Das old/new-Paar macht das Cross-Fade – also gehören Opacity- und Filter-Effekte dort hin. Eine Duration auf ::view-transition-new zu setzen und sich zu fragen, warum das Element trotzdem einrastet, ist einer der häufigsten ersten Fehler mit diesem API.

Das, was alle überrascht: Ein Snapshot ist ein Foto, kein Element

Dies ist das einzige wichtigste mentale Modell, und es ist das, was die Einfachheit des API verbirgt. ::view-transition-old ist nicht dein altes Element, kleiner gerendert. Es ist ein statisches Bild davon, einmalig aufgenommen.

Alles andere folgt daraus. Ein <video> innerhalb eines Übergangs friert für die Dauer auf einem Frame ein. Ein Spinner hört auf zu drehen. Text im Snapshot kann nicht ausgewählt werden und ist nicht für einen Screenreader erreichbar – das echte DOM darunter wurde bereits ersetzt, und was man sieht, ist ein Bild von etwas, das nicht mehr existiert. Ein GIF stoppt. Eine CSS-Animation innerhalb des erfassten Elements pausiert auf halbem Weg.

Bei 200ms spielt das keine Rolle. Bei 1000ms wird alles offensichtlich und leicht kaputt. Das ist der eigentliche Grund, Übergänge kurz zu halten – bei einer bestimmten Dauer sieht der Benutzer keinen Übergang mehr, sondern einen eingefrorenen Screenshot der App.

Das erklärt auch die Seitenverhältnis-Falle. Wenn das alte Element eine 1:1-Miniatur ist und das neue ein 16:9-Hero, interpoliert der Browser zwischen zwei Bildern unterschiedlicher Form, und es entsteht ein sichtbares Quetschen im Flug. Die Lösung ist, beiden Elementen dasselbe Seitenverhältnis zu geben, oder object-fit auf den old/new-Pseudo-Elementen zu überschreiben.

Benennung: der gesamte Vertrag

Standardmäßig bekommt man ein Cross-Fade der gesamten Seite. Das interessante Verhalten beginnt, wenn man einem Element einen view-transition-name gibt. Ein benanntes Element wird aus dem Seiten-Snapshot herausgelöst und erhält seine eigene Gruppe, die der Browser nach Position und Größe interpoliert – nicht nur nach Opacity. Gleicher Name auf beiden Seiten bedeutet, dass der Browser eines in das andere bewegt und skaliert. Das ist FLIP, in CSS erledigt.

css

.card-image {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}

Die eine harte Regel: Ein view-transition-name muss zu jedem Zeitpunkt eindeutig im Dokument sein. Zwei Elemente mit gleichem Namen lassen den gesamten Übergang scheitern – nicht nur dieses Element, das Ganze – und es scheitert lautlos. Bei einer Liste von Karten ist das das Standardergebnis. Die Lösung: Den Namen aus der ID des Datensatzes ableiten.

React 19.2 und Next.js 16: eine Komponente

Das von Hand in einem Framework zu tun war immer umständlich, weil der Moment des DOM-Updates React gehört, nicht dir. React löst es mit der <ViewTransition>-Komponente, und Next.js nutzt sie seit Version 16 ohne Konfiguration – der App Router läuft auf React-Canary-Builds, die sie bereits enthalten, und Routen-Navigationen sind React Transitions.

tsx

import { ViewTransition } from "react";
import Image from "next/image";
import Link from "next/link";
export function ProjectCard({ project }) {
return (
<Link href={"/projects/" + project.slug}>
<ViewTransition name={"project-" + project.slug}>
<Image src={project.cover} alt={project.title} />
</ViewTransition>
</Link>
);
}

Auf der Detailseite wickelt man das große Bild in ein <ViewTransition> mit demselben name ein, und React findet das Paar und morpht die Miniatur in den Hero. Zurückgehen kehrt es um. Beachtenswert ist, was fehlt: keine Exit-Animationen, kein AnimatePresence, kein Messen, keine Layout-IDs.

Ein Detail aus der Next.js-Dokumentation verdient Wiederholung, weil es lautlos scheitert: share und default="none" müssen zusammen auftreten. Mit default="none" und ohne share-Prop hört das benannte Paar still auf, sich zu morphen.

Richtung: dem Benutzer sagen, wohin er gegangen ist

Ein Morph sagt dem Benutzer was sich bewegt hat. Richtung sagt ihm wo er ist – tiefer oder zurück. Next.js 16.2 fügte transitionTypes zu <Link> hinzu, wodurch <ViewTransition> jeden Tag auf eine andere Animation abbilden kann.

tsx

<Link href="/projects/runvalis" transitionTypes={["nav-forward"]}>
Runvalis
</Link>
<ViewTransition
enter={{ "nav-forward": "slide-in", "nav-back": "slide-out", default: "none" }}
exit={{ "nav-forward": "slide-out", "nav-back": "slide-in", default: "none" }}
default="none"
>
<main>{children}</main>
</ViewTransition>

Zwei Dinge sind leicht falsch zu machen: Der Wrapper muss in jedem page.tsx stehen, nicht im Layout – Layouts bleiben über Navigationen hinweg bestehen, also tritt ihr Inhalt nie ein oder aus. Und der eigene Zurück-Button des Browsers trägt keinen Übergangstyp.

Die Version ganz ohne Framework

Wert zu wissen, auch wenn man Next.js nie verlässt: Derselbe Mechanismus existiert für gewöhnliche Multi-Page-Sites, ganz ohne JavaScript.

css

@view-transition {
navigation: auto;
}

Das ist die gesamte Einrichtung. view-transition-name funktioniert dann dokumentübergreifend. Es ist das stärkste Argument dafür, dass dieses API keine React-Funktion ist, die zufällig CSS verwendet, sondern eine Browser-Funktion, die React zufällig schön exponiert.

Fallstricke und Best Practices

  • Immer als Progressive Enhancement behandeln. Wo der Browser keinen Support hat, bricht nichts – die Navigation passiert einfach sofort, wie immer.
  • Im Zeitlupenmodus debuggen. Das Animations-Panel in Chrome DevTools lässt die Wiedergabe auf 25% oder 10% drosseln.
  • prefers-reduced-motion respektieren. Richtungsschübe simulieren Bewegung über den gesamten Viewport – ein Lehrbuch-Auslöser für vestibuläre Beschwerden.
  • Die Überlagerung frisst Klicks standardmäßig. ::view-transition { pointer-events: none } setzen, damit sie durchkommen.
  • Snapshots kosten Speicher. Nur das benennen, was Bedeutung trägt.
  • Den Header verankern. Wenn der gesamte Viewport gleitet, verliert der Benutzer seinen festen Bezugspunkt.
  • Ein View Transition ist für assistive Technologie unsichtbar. Es bewegt keinen Fokus und kündigt nichts an.

Fazit

Der INP-Artikel endete mit der Idee, dass wahrgenommene Performance nur so stark ist wie ihr schwächstes Glied, und dass das schwächste Glied meistens das eigene JavaScript ist. View Transitions sind dasselbe Argument, auf Navigation angewendet: Anstatt FLIP von Hand auszuführen, deklariert man was zwei Zustände verbindet und lässt den Browser die Verbindung aus zwei Fotos ziehen.