← All posts
EngineeringAug 6, 20264 min read

A Wipe Transition for Dark/Light Mode, No Flash, No Libraries

How this site switches themes with a full-screen wipe instead of an instant color swap — and why the animation runs outside of React state.

Why Not Just Toggle a Class?

The standard dark mode toggle flips a dark class on and calls it done. That works, but the instant color swap always felt a little abrupt to me — like flicking a light switch instead of anything intentional. I wanted the toggle to feel like a deliberate moment, not a flicker.

Three Phases, One Overlay

The theme toggle animates a single fixed, full-screen div in three phases:

  • Cover — the overlay scales down from the top, covering the screen in the *next* theme's background color
  • Switch — once fully covered, the actual theme class flips underneath, completely hidden from view
  • Reveal — the overlay scales away from the bottom, uncovering the new theme
  • wipe.style.transform = "scaleY(0)";
    wipe.style.transformOrigin = "top";
    wipe.style.transition = "transform 0.6s cubic-bezier(0.65, 0, 0.35, 1)";
    wipe.style.transform = "scaleY(1)"; // cover
    
    setTimeout(() => {
      setTheme(next);
      document.documentElement.classList.toggle("dark", next === "dark");
    }, 620); // switch, while fully covered

    Because the actual class swap happens while the overlay is 100% opaque, there's no visible flash of mismatched colors mid-transition — the user only ever sees one theme or a solid wipe, never both at once.

    Why This Skips React State for the Animation

    The wipe itself is driven with direct style mutations on a ref, not useState. Animating 60fps transform/opacity changes through React re-renders is unnecessary work for something this transient — the DOM node is grabbed once via useRef, and the animation just mutates its style directly. React only gets involved for the one meaningful state change: which theme is active.

    Avoiding the Flash on Load

    Theme preference is read from localStorage in a useEffect, and the provider renders nothing extra until that read completes (mounted state). Combined with suppressHydrationWarning on , this avoids the classic dark-mode flash where the page briefly renders in the wrong theme before JavaScript catches up.

    Result

    Theme switching feels like an actual transition instead of a snap — and it's built entirely with refs and native style mutations, no animation library involved.