Was wäre, wenn sich auf Deiner Website das Ändern in den Dunkelmodus weniger wie ein abrupter Schalter und mehr wie eine filmreifer Übergang anfühlen würde?

Moderne Nutzer sehnen sich nach Benutzeroberflächen, die sich flüssig und lebendig anfühlen. Die meisten Übergänge zwischen Hell- und Dunkelmodus sind abrupt. Dieses plötzliche Aufblitzen von Pixeln unterbricht oft das Eintauchen des Nutzers in die Website.

Die moderne View Transition API ändert dies, indem sie die Lücke zwischen statischen DOM-Zuständen überbrückt. Sie ermöglicht es uns, mit nur wenigen Zeilen CSS und JavaScript nahtlos animierte Übergänge zwischen Seitenansichten zu erstellen.

In diesem Beitrag gehen wir über den einfachen Umschaltvorgang hinaus. Ich zeige Dir, wie Du eine standardmässige UI-Notwendigkeit in ein ansprechendes, ausgefeiltes Erlebnis verwandeln können. Wir werden eine App erstellen, die verschiedene Möglichkeiten aufzeigt, wie man Theme-Übergänge zu einem Augenschmaus macht.

Vorschau auf das Demo-Projekt

Schau dir den Übergang unten an. Das ist nur einer der Effekte, die wir in dieser Anleitung erstellen werden.

Ziemlich cool, oder? Im Vergleich zum abrupten, störenden „Ruck“ eines typischen Dark-Mode-Wechsels wirkt dieser Übergang bewusst gestaltet und unglaublich flüssig. Es ist ein kleines Detail, das einen riesigen Unterschied darin macht, wie ein Nutzer die Qualität Ihrer App wahrnimmt. Wir werden über einfache Umschaltfunktionen hinausgehen, um einfache Designänderungen in herausragende Funktionen zu verwandeln, die die Aufmerksamkeit der Nutzer auf sich ziehen.

Demo project preview, showcasing one of many view transition examples, specifically a radial-fill transition
Vorschau auf das Demoprojekt, das eines von vielen Übergängen zeigt

Projekt Setup

Installation

Um eine solide Grundlage zu schaffen, setzen wir Nuxt in Verbindung mit NuxtUI ein. Diese Kombination bietet eine Bibliothek aus ausgereiften Komponenten, die von Haus aus die Gestaltung von Designs unterstützen. Praktischerweise bietet Nuxt mehrere Starter-Vorlagen, um Projekte schnell aufzusetzen, darunter auch eine spezielle Konfiguration für NuxtUI.

$ npm create nuxt@latest -- -t ui

Theme Konfiguration

Sobald die Installation abgeschlossen ist, können wir auch direkt eintauchen. NuxtUI bietet dir eine intuitive Möglichkeit, deinen visuellen Stil zu definieren, noch bevor du überhaupt die erste Zeile Code schreibst. Nutze am besten die Theme-Palette der Dokumentation, um die gewünschten Eigenschaften anzupassen. Du wirst sehen, dass die Änderungen sofort in allen Komponenten-Vorschauen übernommen werden.

Showcase of the theme configurator on the NuxtUI website
Showcase des Theme-Konfigurators auf der NuxtUI-Website

Sobald du deine Einstellungen ausgewählt hast, kopiere einfach die Theme-Konfigurationen. Nutze dafür die Buttons für die main.css und die app.config.ts, die du im Pop-up unter dem Abschnitt „Export“ findest.

Als Nächstes müssen wir diese Snippets nur noch in die entsprechenden Dateien einfügen. Für meine Demo habe ich mich für ein schlichtes Schwarz-Weiss-Theme entschieden:

@import 'tailwindcss';
@import '@nuxt/ui';

@theme {
  --font-sans: 'Inter', sans-serif;
}

:root {
  --ui-radius: 0.125rem;
  --ui-primary: black;
}

.dark {
  --ui-primary: white;
}
export default defineAppConfig({
  ui: {
    colors: {
      primary: 'white',
      neutral: 'zinc'
    }
  }
})

Eine Demo-App erstellen

Als Nächstes machen wir uns daran, das eigentliche Markup unserer Seite zu gestalten. Da wir mit NuxtUI arbeiten, müssen wir glücklicherweise nicht alles von Grund auf neu bauen. Stattdessen greifen wir auf vorgefertigte Komponenten zurück, um direkt ein optisch ansprechendes Setup zu erhalten. Indem wir diese Elemente kombinieren, schaffen wir ein sauberes, funktionales Layout. Dieser Ansatz ermöglicht es uns, den Fokus voll und ganz auf die Struktur und das Verhalten zu legen, anstatt uns zu früh im Detail-Styling zu verlieren.

Die app/app.vue bildet den Einstiegspunkt unserer Anwendung. Sie umschliesst dabei alles mit der UApp-Komponente von NuxtUI, um für die nötige Grundstruktur zu sorgen. Auf diese Weise werden der Header und der Dummy-Content zu einem stimmigen Gesamtlayout zusammengeführt.

<template>
  <UApp>
    <!-- Our theme switch will be in AppHeader -->
    <AppHeader />
    <UMain>
      <UContainer>
        <!-- Our dummy page content will be in AppContent -->
        <AppContent />
      </UContainer>
    </UMain>
  </UApp>
</template>

Die app/components/AppContent.vue ist dafür zuständig, den eigentlichen Platzhalter-Content der Seite zu rendern. Dabei sorgt UPageHero von NuxtUI für einen optisch ansprechenden Bereich. Diese Komponente liefert uns also schon während der Entwicklung konkrete Inhalte für unsere App.

<script setup lang="ts">
import type { ButtonProps } from '@nuxt/ui'

const links: ButtonProps[] = [
  {
    label: 'Get started',
    to: '#',
    icon: 'i-lucide-square-play'
  },
  {
    label: 'Learn more',
    to: '#',
    color: 'neutral',
    variant: 'subtle',
    trailingIcon: 'i-lucide-arrow-right'
  }
]
</script>

<template>
  <UPageHero
    title="Level Up Your Themes Using View Transition API"
    description="Make your themes stand out with more than just colors"
    headline="View Transition API + Theme Transition"
    orientation="vertical"
    :links
  >
    <!-- "/pic.png" refers to public/pic.png -->
    <img
      src="/pic.png"
      alt="App screenshot"
      class="rounded-lg shadow-2xl ring ring-default"
    />
  </UPageHero>
</template>

Die app/components/AppHeader.vue definiert den Header der Seite. Diese Komponente nutzt das UNavigationMenu von NuxtUI für eine aufgeräumte Navigationsleiste und beherbergt zudem unseren Button zum Wechseln des Themes.

<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'

const links: NavigationMenuItem[] = [
  { label: 'Awesome', to: '#' },
  { label: 'Theme', to: '#' },
  { label: 'Transitions', to: '#' }
]
</script>

<template>
  <div
    class="fixed top-2 sm:top-4 mx-auto left-1/2 transform -translate-x-1/2 z-10"
  >
    <UNavigationMenu
      :items="links"
      variant="link"
      color="neutral"
      class="bg-muted/80 backdrop-blur-sm rounded-full px-2 sm:px-4 border border-muted/50 shadow-lg shadow-neutral-950/5"
      :ui="{ link: 'px-2 py-1' }"
    >
      <template #list-trailing>
       <!-- The main star here is the AppThemeSwitch -->
        <AppThemeSwitch />
      </template>
    </UNavigationMenu>
  </div>
</template>
<script setup lang="ts">
// NuxtUI composable which allows us to read/modify theme mode
const colorMode = useColorMode()

// Get next theme based on currently active one
const nextTheme = computed(() =>
  colorMode.value === 'dark' ? 'light' : 'dark'
)

// Toggle theme
const switchTheme = () => {
  colorMode.preference = nextTheme.value
}

// The main attraction of the theme switching process.
// This function will invoke the view transitions.
const startViewTransition = (event: MouseEvent) => {
 // ... but only if the client's browser support this API!
 // Otherwise we will just do an instant theme switch.
 if (!document.startViewTransition) {
   switchTheme()
   return
 }

 // Invoke view transition
 const transition = document.startViewTransition(() => {
   switchTheme()
 })

 // This piece will handle the theme transitions, by providing
 // a callback function that tells the browser how to
 // transition/animate between the page views.
 // We'll leave it empty for now
 transition.ready.then(() => { /* action will happen here */ })
}
</script>

<template>
  <ClientOnly>
    <UButton
      :aria-label="`Switch to ${nextTheme} mode`"
      :icon="`i-lucide-${nextTheme === 'dark' ? 'sun' : 'moon'}`"
      color="neutral"
      variant="ghost"
      size="sm"
      class="rounded-full bg-def"
      @click="startViewTransition"
    />
    <template #fallback>
      <div class="size-4" />
    </template>
  </ClientOnly>
</template>

<style>
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

::view-transition-new(root) {
  z-index: 9999;
}
::view-transition-old(root) {
  z-index: 1;
}
</style>

Konfiguration der Übergangsschichten

Dieses CSS passt das Verhalten der View Transition beim Wechsel zwischen den Zuständen an. Standardmässig wendet der Browser eine vordefinierte Animation an, die wir hier jedoch explizit deaktivieren, indem wir animation: none für die alten und neuen Views setzen. Das gibt uns erst mal die volle Kontrolle darüber, wie der Übergang tatsächlich aussehen soll.

Die Regel mix-blend-mode: normal stellt sicher, dass beide Ebenen ohne jegliche Blend-Effekte gerendert werden, sodass die Farben während des Übergangs exakt so erscheinen, wie man es erwartet.

Zudem steuern wir das Stacking der beiden Zustände: Der neue View wird per hohem z-index nach oben geholt, während der alte View darunter bleibt. Diese Ebenen-Hierarchie ist entscheidend, um eigene Transitions sauber zu definieren. So überdeckt der neue Zustand den alten einfach glatt und ohne störende Interferenzen.

Was wir bisher aufgebaut haben

Bevor wir uns in die Implementierungsdetails stürzen, verschaffen wir uns erst mal einen visuellen Überblick. Diese Vorschau zeigt unseren bisherigen Fortschritt und verdeutlicht, wie schnell man eine polierte App auf die Beine stellen kann. Dank NuxtUI funktioniert der Theme-Support quasi out-of-the-box: Elemente wie Texte und Buttons passen sich automatisch an den Light- oder Dark-Mode an, ohne dass wir dafür eine einzige Zeile eigenes CSS schreiben mussten.

Gleichzeitig zeigt dieser Showcase aber auch eine kleine Schwäche des Standard-Themings auf: Die Farben aktualisieren sich zwar korrekt, aber der sofortige Wechsel wirkt doch recht abrupt. Als Nächstes werden wir daher die View Transition API nutzen, um für ein sanfteres und hochwertigeres Erlebnis zu sorgen.

The current state of the demo application, showcasing the theme transition without active usage of the View Transition API
Showcase of the current state of the demo application, showcasing the theme transition

View Transition API In Action

In den folgenden Abschnitten werden wir eine Reihe von Funktionen definieren, die sich um die eigentlichen Transitions kümmern. Alle diese Funktionen werden an derselben Stelle aufgerufen, und zwar in der app/components/AppThemeSwitch.vue. Genauer gesagt übergeben wir sie dort als Argument an transition.ready.then(...).

Übergang mit horizontalem Swipe-Effekt

Showcase of the "horizontal swipe" theme transition using the View Transition API
Showcase der „Horizontal Swipe“-Theme-Transition mithilfe der View Transition API
export const horizontalSwipeTransition = () => {
  const keyframes = {
    clipPath: [
      // Start fully clipped (nothing visible yet)
      'inset(0 100% 0 0)',
      // End fully visible
      'inset(0 0% 0 0)'
    ]
  }

  document.documentElement.animate(keyframes, {
    duration: 1200,
    easing: 'cubic-bezier(0.76, 0.32, 0.29, 0.99)',
    pseudoElement: '::view-transition-new(root)'
  })
}

Diese Funktion definiert eine eigene View-Transition-Animation, die das neue Theme mit einem horizontalen „Swipe“-Effekt zum Vorschein bringt.

Das Ganze funktioniert so: Wir animieren die clip-path-Eigenschaft, die steuert, welcher Teil eines Elements sichtbar ist. Zu Beginn der Animation ist der neue View durch inset(0 100% 0 0) komplett „weggeclippt“, also unsichtbar. Während die Animation läuft, wird das Clipping schrittweise reduziert, bis es inset(0 0% 0 0) erreicht und der gesamte View sichtbar wird. Das Ergebnis ist ein flüssiger Reveal-Effekt, der so aussieht, als würde das neue Theme von der Seite her einschieben.

Angewendet wird die Animation über document.documentElement.animate, wodurch wir direkt das Root-Element der Seite ansteuern. Die Option pseudoElement: '::view-transition-new(root)' stellt dabei sicher, dass wir wirklich den neuen Zustand animieren und nicht den alten.

Zu guter Letzt sorgen die Dauer und eine benutzerdefinierte Easing-Kurve für das richtige Timing und ein stimmiges Gefühl – das macht die Bewegung deutlich dynamischer und polierter als eine einfache lineare Transition. Um die Funktion schliesslich zu registrieren, übergibst du sie einfach als Callback an transition.ready.then in der app/components/AppThemeSwitch.vue.

// old
transition.ready.then(() => { /* action will happen here */ })

// new
transition.ready.then(horizontalSwipeTransition)




Übergang mit diagonalem Swipe-Effekt

Showcase of the "diagonal swipe" theme transition using the View Transition API
Showcase des Übergangs mit „Diagonal Swipe“ unter Verwendung der View Transition API
export const diagonalSwipeTransition = () => {
  const keyframes = {
    clipPath: [
      // Start as a single invisible point at the bottom-right
      'polygon(100% 100%, 100% 100%, 100% 100%)',
      // Expand into a giant triangle.
      // We overshoot past 0% (to -150%) to guarantee it covers the top-left corner completely.
      'polygon(100% 100%, -150% 100%, 100% -150%)'
    ]
  }

  document.documentElement.animate(keyframes, {
    duration: 1200,
    easing: 'cubic-bezier(0.76, 0.32, 0.29, 0.99)',
    pseudoElement: '::view-transition-new(root)'
  })
}

Diese Transition basiert auf derselben Idee wie der horizontale Swipe aus dem vorherigen Abschnitt, ändert jedoch die Form und die Richtung des Reveal-Effekts.

Anstatt inset() zu nutzen, animieren wir hier einen clip-path, der als Polygon definiert ist. Die Animation beginnt als einzelner Punkt in der unteren rechten Ecke, an dem sich alle Polygon-Koordinaten überschneiden, sodass der neue View zunächst komplett unsichtbar ist. Von dort aus dehnt sich die Form zu einem grossen Dreieck aus, das diagonal über den Bildschirm wächst.

Die Koordinaten ragen dabei über den sichtbaren Bereich hinaus (bis auf -150 %), um sicherzustellen, dass der gesamte Viewport am Ende der Animation auch wirklich vollständig abgedeckt ist. Wie zuvor wird die Animation auf das Pseudoelement ::view-transition-new(root) angewendet, sodass wir den neuen Zustand der Seite zum Vorschein bringen.

Das Ergebnis ist ein diagonaler Swipe-Effekt, der sich im Vergleich zur einfachen horizontalen Transition deutlich dynamischer anfühlt. Um diese Funktion schliesslich zu registrieren, übergibst du sie einfach als Callback an transition.ready.then in der app/components/AppThemeSwitch.vue.

// old
transition.ready.then(() => { /* action will happen here */ })

// new
transition.ready.then(diagonalSwipeTransition)

Übergang mit Unschärfeeffekt

Showcase of the "blur" theme transition
Showcase des «Blur»-Übergangs unter Verwendung der View Transition API
export const blurTransition = () => {
  const keyframes = [
    {
      // Start: Completely transparent, heavily blurred
      opacity: 0,
      filter: 'blur(30px)',
      transform: 'scale(1.05)'
    },
    {
      // End: Fully opaque, perfectly sharp
      opacity: 1,
      filter: 'blur(0px)',
      transform: 'scale(1)'
    }
  ]

  document.documentElement.animate(keyframes, {
    duration: 600,
    easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
    pseudoElement: '::view-transition-new(root)'
  })
}

Diese Transition verfolgt einen anderen Ansatz als die bisherigen Swipe-Animationen: Hier liegt der Fokus auf einem sanften Einblenden, kombiniert mit einem Weichzeichner und einer leichten Skalierung.

Zu Beginn der Animation ist der neue View komplett transparent (opacity: 0), stark verschwommen (blur(30px)) und leicht herangezoomt (scale(1.05)). Dadurch wirkt er zunächst distanziert und unscharf. Im Verlauf der Animation geht er dann fliessend in einen voll sichtbaren und scharfen Zustand über (opacity: 1, blur(0px)), während er gleichzeitig auf seine normale Grösse zurückgesetzt wird (scale(1)). So entsteht ein subtiler Effekt, bei dem das neue Theme gewissermassen „scharfgestellt“ wird.

Genau wie bei den anderen Transitions wird die Animation auf das Pseudoelement ::view-transition-new(root) angewendet. Das bedeutet, dass sie ausschliesslich den reinkommenden Seitenzustand während der View Transition beeinflusst. Das Ergebnis ist ein deutlich weicherer und dezenterer Stil als bei den richtungsgebundenen Swipes – mit dem Fokus auf Geschmeidigkeit statt auf harter Bewegung.

Um diese Funktion schliesslich zu registrieren, übergibst du sie einfach als Callback an transition.ready.then in der app/components/AppThemeSwitch.vue.

// old
transition.ready.then(() => { /* action will happen here */ })

// new
transition.ready.then(blurTransition)

Kreisförmiger Übergang

Showcase of the "radial-fill" theme transition
Showcase des Übergangs „Kreis“ mithilfe der View Transition API
export const radialFillTransition = () => {
  const keyframes = {
    clipPath: [
      // Start: tiny circle at top-center (0% X, 0% Y)
      'circle(0% at 50% 0%)',
      // End: large circle covering entire screen
      'circle(150% at 50% 0%)'
    ]
  }

  document.documentElement.animate(keyframes, {
    duration: 1200,
    easing: 'cubic-bezier(0.76, 0.32, 0.29, 0.99)',
    pseudoElement: '::view-transition-new(root)'
  })
}

Diese Transition erzeugt einen Radial-Fill-Effekt, indem sie einen kreisförmigen clip-path nutzt.

Alles beginnt mit einem Kreis mit dem Radius 0 %, der oben in der Mitte des Bildschirms positioniert ist (50% 0%). Das bedeutet, dass der neue View zu Beginn noch komplett verborgen bleibt. Von dort aus dehnt sich der Kreis immer weiter aus, bis er 150 % erreicht – groß genug also, um den gesamten Viewport abzudecken.

Indem der Kreis von einem einzelnen Punkt aus zu einer vollflächigen Form anwächst, enthüllt sich die neue Seite von einem zentralen Ursprung am oberen Rand aus. Das sorgt für einen organischeren, fast schon Spotlight-artigen Übergang im Vergleich zu den Richtungs-Swipes oder Blur-Effekten, die wir uns zuvor angeschaut haben.

Wie schon bei den anderen Beispielen zielt die Animation auf ::view-transition-new(root) ab, sodass sie während des View-Transition-Prozesses wirklich nur den reinkommenden Zustand beeinflusst. Um diese Funktion schliesslich zu registrieren, übergibst du sie einfach als Callback an transition.ready.then in der app/components/AppThemeSwitch.vue.

// old
transition.ready.then(() => { /* action will happen here */ })

// new
transition.ready.then(radialFillTransition)

Fazit

Was die View Transition API so faszinierend macht, ist nicht nur die Tatsache, dass sie flüssigere Animationen ermöglicht. Vielmehr verwandelt sie das, was im Web früher eine harte Grenze war, in etwas Ausdrucksstarkes und Bewusstes.

Anstatt den Theme-Wechsel lediglich als binäres Umschalten zwischen Hell und Dunkel zu betrachten, erlaubt es dir die API, daraus ein echtes Erlebnis zu machen. Eine simple Änderung der Einstellungen wird so zu einem Moment der Kontinuität: Farben, Oberflächen und Hierarchien können sanft ineinander übergehen, anstatt abrupt umzuspringen. Dieser subtile Wandel hat einen enormen Einfluss auf die wahrgenommene Qualität. Nutzer merken vielleicht gar nicht bewusst, warum sich das Interface besser anfühlt – aber sie spüren es eben doch.

Abseits von Theme-Transitions liegt die eigentliche Stärke hier in der Kontrolle bei minimaler Komplexität. Wo früher noch zerbrechliche DOM-Hacks, Animations-Bibliotheken oder Layout-Gymnastik regierten, erledigen heute ein paar deklarative Hooks den Job. Das bedeutet weniger Code, der gewartet werden muss, weniger Edge-Cases und mehr Raum, um sich auf das Design selbst zu konzentrieren, anstatt auf die Mechanik der Animation.

Und was vielleicht am wichtigsten ist: Die API bringt das Web ein Stück weiter in Richtung einer Eigenschaft, die es schon immer gut beherrscht hat – der Fluidität. Seiten sind nicht mehr nur Ziele, die neu geladen werden oder einander ersetzen. Sie können sich vielmehr wie eine zusammenhängende Fläche verhalten, auf der Veränderungen Teil des Erlebnisses sind und keine Unterbrechung darstellen.

Theme-Transitions sind dabei nur ein Anwendungsbeispiel, aber sie deuten auf einen grösseren Wandel hin: Ein Web, in dem sich Zustandsänderungen wirklich gestaltet anfühlen und nicht bloss implementiert.

Kontaktieren Sie uns

Bei N47 ist diese Liebe zum Frontend-Handwerk fester Bestandteil unserer Arbeitsweise, wenn wir digitale Produkte ganzheitlich entwickeln. Falls du gerade an einer Webanwendung arbeitest und ein Team suchst, dem die Details eben nicht egal sind: Melde dich bei uns – wir sind gespannt auf dein Projekt.

Leave a Reply


The reCAPTCHA verification period has expired. Please reload the page.