Back to Blog
T

Theme Switching with View Transitions API: Analysis and Lessons from Element Plus

April 24, 2026·10 min read
View Transitions APIDark ModeCSS Variables

Table of Contents

  • Introduction
  • Core Architecture
  • Implementation Steps
  • Step 1: CSS Variables and Tailwind Mapping
  • Step 2: FOUC Prevention
  • Step 3: ThemeProvider with useSyncExternalStore
  • Step 4: View Transitions API Circular Reveal Animation
  • Step 5: Icon Animation
  • Pitfalls
  • Pitfall 1: Dual State Manipulation in ThemeToggle
  • Pitfall 2: Module-Level State with Multiple Instances
  • Pitfall 3: CDN Icons Can't Respond to CSS Theming
  • Pitfall 4: MouseGlow Uses MutationObserver Instead of Context
  • Pitfall 5: PWA Manifest Only Supports Light Theme
  • Pitfall 6: Error Page Theme Adaptation
  • Strengths Summary
  • Improvement Directions
  • Summary

Table of Contents

  • Introduction
  • Core Architecture
  • Implementation Steps
  • Step 1: CSS Variables and Tailwind Mapping
  • Step 2: FOUC Prevention
  • Step 3: ThemeProvider with useSyncExternalStore
  • Step 4: View Transitions API Circular Reveal Animation
  • Step 5: Icon Animation
  • Pitfalls
  • Pitfall 1: Dual State Manipulation in ThemeToggle
  • Pitfall 2: Module-Level State with Multiple Instances
  • Pitfall 3: CDN Icons Can't Respond to CSS Theming
  • Pitfall 4: MouseGlow Uses MutationObserver Instead of Context
  • Pitfall 5: PWA Manifest Only Supports Light Theme
  • Pitfall 6: Error Page Theme Adaptation
  • Strengths Summary
  • Improvement Directions
  • Summary

Introduction

While developing my personal website, I was captivated by the theme switching animation on the Element Plus official site — clicking the toggle button triggers a circular reveal animation that expands outward from the button position, rather than a jarring global color swap. This smooth visual transition inspired me to replicate the effect in my Next.js project.

After some experimentation, I built a complete theme switching solution based on React 18's useSyncExternalStore, CSS custom properties, Tailwind CSS 4's @theme inline, and the View Transitions API. This article provides an in-depth analysis of the implementation, highlighting both its strengths and the pitfalls I encountered.

Core Architecture

The theme switching system consists of several core modules:

┌─────────────────────────────────────────────────────┐
│                    Root Layout                       │
│  ┌───────────────────────────────────────────────┐  │
│  │  Inline Script (FOUC Prevention)              │  │
│  │  localStorage → .dark class on <html>         │  │
│  └───────────────────────────────────────────────┘  │
│                      ↓                              │
│  ┌───────────────────────────────────────────────┐  │
│  │  ThemeProvider (React Context)                 │  │
│  │  useSyncExternalStore + module-level cache     │  │
│  │  → sync .dark class via useEffect             │  │
│  └───────────────────────────────────────────────┘  │
│                      ↓                              │
│  ┌───────────────────────────────────────────────┐  │
│  │  ThemeToggle (View Transitions API)            │  │
│  │  startViewTransition + clipPath circle animate │  │
│  └───────────────────────────────────────────────┘  │
│                      ↓                              │
│  ┌───────────────────────────────────────────────┐  │
│  │  globals.css (CSS Custom Properties)           │  │
│  │  :root / .dark → @theme inline → Tailwind     │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

Implementation Steps

Step 1: CSS Variables and Tailwind Mapping

The foundation of theme switching is CSS custom properties. In globals.css, :root defines light theme variables and .dark overrides them for dark mode:

:root {
  --primary: #8FB2C9;
  --bg-primary: #FFFEF9;
  --bg-secondary: #F5F5F0;
  --text-primary: #1A1A1A;
  --text-secondary: #5B6570;
}

.dark {
  --primary: #8FB2C9;
  --bg-primary: #000000;
  --bg-secondary: #111111;
  --text-primary: #E8E8E8;
  --text-secondary: #9CA3AF;
}

Then, Tailwind CSS 4's @theme inline directive maps CSS variables to Tailwind color tokens:

@theme inline {
  --color-primary: var(--primary);
  --color-bg-primary: var(--bg-primary);
  --color-bg-secondary: var(--bg-secondary);
  --color-text-primary: var(--text-primary);
  --color-text-secondary: var(--text-secondary);
}

Components can now use semantic Tailwind classes like bg-bg-primary and text-text-primary. When the .dark class toggles, CSS variables update automatically, and all components respond — no JavaScript required.

Step 2: FOUC Prevention

In Next.js, React hydration is asynchronous. If we wait until React renders to set the .dark class, users will see a flash of light theme before it switches to dark. The solution is an inline script injected into the root layout's <head>:

const themeScript = `
(function() {
  try {
    var stored = localStorage.getItem('theme');
    var theme = stored;
    if (theme !== 'dark' && theme !== 'light') {
      theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  } catch (e) {}
})();
`;

This script executes synchronously when the browser parses the HTML, long before React hydration, ensuring the first paint uses the correct theme. The <html> tag also has suppressHydrationWarning to prevent React from warning about the class mismatch.

Step 3: ThemeProvider with useSyncExternalStore

The core of theme state management is ThemeProvider, using React 18's useSyncExternalStore:

let listeners: (() => void)[] = [];
let cachedTheme: Theme | null = null;

function getSnapshot(): Theme {
  if (cachedTheme !== null) return cachedTheme;
  const stored = safeGetItem("theme") as Theme | null;
  cachedTheme =
    stored === "light" || stored === "dark"
      ? stored
      : window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
  return cachedTheme;
}

function getServerSnapshot(): Theme {
  return "light";
}

Key design decisions:

Design ChoiceRationale
Module-level cachedThemeuseSyncExternalStore may call getSnapshot multiple times in concurrent mode; caching ensures consistent returns within the same render cycle
getServerSnapshot returns "light"localStorage is unavailable during SSR; returning a default prevents hydration mismatches
safeGetItem / safeSetItemlocalStorage may be inaccessible in private browsing mode; try-catch protects against errors
mounted checkReturns "light" and no-op functions during SSR to prevent hydration mismatches

A useEffect synchronizes React state to the DOM:

useEffect(() => {
  const root = document.documentElement;
  if (theme === "dark") {
    root.classList.add("dark");
  } else {
    root.classList.remove("dark");
  }
}, [theme]);

Step 4: View Transitions API Circular Reveal Animation

This is the most exciting part — the core inspiration from Element Plus. When the user clicks the toggle button, the new theme expands outward from the button position in a circular reveal:

const handleClick = async () => {
  const button = buttonRef.current;
  if (!button) return;

  const rect = button.getBoundingClientRect();
  const x = rect.left + rect.width / 2;
  const y = rect.top + rect.height / 2;

  const doc = document as Document & {
    startViewTransition?: (cb: () => void) => ViewTransition;
  };

  // Graceful degradation: direct toggle when View Transitions API unavailable
  if (!doc.startViewTransition) {
    toggleTheme();
    setShouldAnimate(true);
    return;
  }

  const isCurrentlyDark = document.documentElement.classList.contains('dark');

  // Disable CSS transitions to prevent interference with View Transition
  document.documentElement.classList.add('no-transition');

  try {
    const transition = doc.startViewTransition(() => {
      // Toggle DOM class inside the callback
      if (isCurrentlyDark) {
        document.documentElement.classList.remove('dark');
      } else {
        document.documentElement.classList.add('dark');
      }
    });

    await transition.ready;

    // Calculate the radius — distance from button center to farthest viewport corner
    const radius = Math.sqrt(
      Math.max(x, window.innerWidth - x) ** 2 +
      Math.max(y, window.innerHeight - y) ** 2
    );

    // Circular clipPath animation
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${radius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );

    await transition.finished;
    // Sync React state after animation completes
    toggleTheme();
    setShouldAnimate(true);
  } finally {
    requestAnimationFrame(() => {
      document.documentElement.classList.remove('no-transition');
    });
  }
};

Paired with CSS for View Transition styling:

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}

::view-transition-old(root) {
  z-index: 1;
}

::view-transition-new(root) {
  opacity: 1;
  z-index: 9999;
}

.no-transition *,
.no-transition *::before,
.no-transition *::after {
  transition: none !important;
}

The .no-transition class is critical — during the View Transition animation, CSS transitions (like transition: color 200ms) would cause colors to gradually shift instead of instantly changing, breaking the circular reveal visual effect.

Step 5: Icon Animation

The toggle button icon uses Framer Motion for rotation and scale animations:

<m.div
  initial={false}
  animate={{
    rotate: isDark ? 360 : 0,
    scale: shouldAnimate ? (isDark ? [0.8, 1] : [1, 0.8]) : 1,
  }}
  transition={{
    duration: 0.5,
    ease: [0.4, 0, 0.2, 1],
  }}
>
  {isDark ? <MoonIcon /> : <SunIcon />}
</m.div>

Pitfalls

Pitfall 1: Dual State Manipulation in ThemeToggle

In handleClick, I manually toggle the .dark class inside the startViewTransition callback, then call toggleTheme() after the animation completes. This separates the DOM and React state update timelines — ThemeProvider's useEffect will attempt to sync the class again, potentially causing race conditions.

The ideal approach would be to call toggleTheme() inside the startViewTransition callback and let ThemeProvider's useEffect handle all DOM operations. However, View Transitions requires synchronous DOM modifications in the callback to capture snapshots, while React state updates are asynchronous — creating an inherent tension. The current implementation works but carries risk.

Pitfall 2: Module-Level State with Multiple Instances

listeners and cachedTheme are module-level variables. The project has separate ThemeProvider instances for the frontend and admin, but they share the same module state. Both useEffect hooks manipulate document.documentElement.classList, which could theoretically interfere with each other.

Pitfall 3: CDN Icons Can't Respond to CSS Theming

The Simple Icons CDN used in SkillsSection determines icon colors via @media (prefers-color-scheme), which can't detect the website's custom theme. I had to write a getThemedIconUrl function that manually parses the URL and selects the appropriate color based on the current theme:

function getThemedIconUrl(url: string, theme: 'light' | 'dark'): string {
  if (!url.includes('cdn.simpleicons.org')) return url;
  const urlObj = new URL(url);
  const parts = urlObj.pathname.split('/').filter(Boolean);
  if (parts.length < 3) return url;
  const [slug, lightColor, darkColor] = parts;
  const color = theme === 'dark' ? darkColor : lightColor;
  urlObj.pathname = color === '_' ? `/${slug}` : `/${slug}/${color}`;
  return urlObj.toString();
}

This solves the problem but triggers component re-renders and icon URL changes on every theme switch.

Pitfall 4: MouseGlow Uses MutationObserver Instead of Context

The MouseGlow component uses MutationObserver on document.documentElement to detect theme changes rather than the useTheme() hook. This was done because MouseGlow needs to respond instantly to theme changes, and React state updates may have a slight delay. However, from an architectural perspective, this adds an unnecessary dependency on DOM structure.

Pitfall 5: PWA Manifest Only Supports Light Theme

manifest.ts hardcodes theme_color and background_color to light theme values. In dark mode, the mobile browser's status bar color won't match. The Web Manifest spec currently doesn't support media-query-based dynamic theme colors — a known platform limitation.

Pitfall 6: Error Page Theme Adaptation

global-error.tsx renders outside the React tree and can't use ThemeProvider. I had to use inline styles with hardcoded light theme colors, then override them via a script that detects dark mode. This approach is fragile and can't leverage the CSS variable system.

Strengths Summary

StrengthDescription
FOUC PreventionInline script ensures correct first paint
View Transitions AnimationCircular reveal effect with graceful degradation
useSyncExternalStoreConcurrent-safe, prevents tearing
Module-Level CacheAvoids repeated localStorage reads
CSS Variables + Tailwind MappingSemantic classes, easy maintenance
prefers-color-schemeRespects system preference
prefers-reduced-motionRespects user motion preferences
i18n SupportToggle button labels are internationalized

Improvement Directions

  1. Unified Theme Control Entry: Make ThemeProvider the sole operator of DOM class changes; ThemeToggle triggers state changes via callbacks only
  2. Singleton DOM Sync: Extract DOM operations from useEffect to module level to avoid multi-instance conflicts
  3. System Preference Listener: Add matchMedia('(prefers-color-scheme: dark)') change event listener
  4. Consistent Theme Approach: Use CSS variables exclusively, remove mixed dark: Tailwind prefix usage
  5. Firefox Fallback Animation: Provide CSS transition fallback for browsers without View Transitions API support

Summary

Implementing theme switching inspired by Element Plus gave me a deep appreciation for the View Transitions API — a few lines of code can achieve effects that previously required complex Canvas or CSS animations. React 18's useSyncExternalStore also provides a safer way to manage external state.

However, in practice, there's an inherent tension between React's asynchronous state updates and the View Transitions API's requirement for synchronous DOM operations. The CSS variable and Tailwind integration is elegant, but edge cases like CDN resources, PWA manifests, and error pages still require additional adaptation work.

Theme switching may seem simple on the surface, but it touches multiple dimensions: SSR, concurrent mode, browser API compatibility, and accessibility. I hope this article provides useful reference for developers implementing similar functionality.