EmpireUI
Get Pro
← Blog7 min read#paint-holding#browser-api#navigation-ux

Paint Holding: Eliminating Flash of Blank Before Navigation

Paint Holding delays the browser's first paint until content is ready, killing that jarring white flash on navigation. Here's how to implement it in React apps.

Browser window displaying smooth page transition without white flash between navigations

What Actually Causes the Flash of Blank

Honestly, the white flash between page navigations is one of the most embarrassing things you can ship. Users see it constantly, designers hate it, and yet it persists across even well-funded products. Let's talk about what's actually happening at the browser level.

When a user clicks a link, the browser starts unloading the current document and begins parsing the next one. For a brief window — sometimes just 50ms, sometimes a full 200ms on slower connections — the compositor has nothing to paint. The result is that stark white rectangle where your content used to be.

The root cause isn't slow JavaScript. It's a timing mismatch between when the old page unpaints and when the new page's first meaningful render is ready. Classic multi-page apps suffer from this by default. Even SPAs can exhibit it when frameworks unmount the current component tree before the next route's data is ready.

Paint Holding is the browser's native answer to this problem. Chrome shipped it in version 86 (around late 2020), and it's now available in all Chromium-based browsers. The idea: delay showing the new document's first paint until either the content is ready or a 500ms timeout expires — whichever comes first.

How Paint Holding Works Under the Hood

Paint Holding is not a JavaScript API you call. It's a browser heuristic that fires automatically for same-origin navigations in Chromium. The browser holds back the first compositor frame of the incoming page, keeping the previous page visible in the meantime. From the user's perspective, the old page stays frozen rather than flashing white.

The 500ms cap is not configurable — it's a hard ceiling. If your new page hasn't produced a meaningful paint within half a second, the browser gives up and shows whatever partial state it has. That means Paint Holding is a complement to fast loading, not a substitute for it.

There's an important interaction with the beforeunload event here. If your page fires beforeunload (even if the handler does nothing substantial), Paint Holding may be bypassed entirely in some browser builds. This is why you'll see guidance to avoid attaching empty beforeunload listeners — they carry a real UX cost beyond just slowing down unload.

Cross-origin navigations don't get Paint Holding. If you're redirecting through an analytics pixel or a third-party auth handler, the browser can't hold the paint because it doesn't know whether the cross-origin page is safe to keep visible. Plan your redirect chains with this in mind.

Detecting Whether Paint Holding Is Active

You can't directly query whether Paint Holding fired. But you can use the Navigation Timing API to infer what happened. The activationStart timestamp on PerformanceNavigationTiming entries tells you when the page became active — if it's nonzero, the page was prerendered or held.

Here's a practical snippet that logs timing deltas you can send to your analytics pipeline:

// utils/paint-timing.ts
export function reportPaintHoldingMetrics() {
  if (typeof window === 'undefined') return;

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.entryType === 'navigation') {
        const nav = entry as PerformanceNavigationTiming;
        const holdDelta = nav.responseStart - nav.fetchStart;
        const paintDelay = nav.domContentLoadedEventEnd - nav.responseStart;

        console.log({
          holdDelta,         // how long the fetch blocked (paint-held window)
          paintDelay,        // time until DOM content loaded after response
          activationStart: nav.activationStart, // nonzero = page was prerendered
        });
      }
    }
  });

  observer.observe({ type: 'navigation', buffered: true });
}

Wire this up in your root layout component or _app.tsx. Don't spam your analytics endpoint with every navigation — sample at 10% and aggregate on the server side. The data will tell you whether your actual users are benefiting from Paint Holding or whether something in your stack is inadvertently disabling it.

View Transitions API: The Intentional Version

Paint Holding is passive — it happens to you. The View Transitions API is active — you opt in and control the crossfade. The two aren't mutually exclusive. In fact, they stack well together: Paint Holding handles the initial blank prevention, while View Transitions handles the visual crossfade between the old and new states.

View Transitions landed in Chrome 111 for same-document transitions and Chrome 126 for cross-document (MPA) transitions. Safari added cross-document support in version 18.2. Firefox is still behind as of late 2026 — keep that in mind when deciding how aggressively to rely on it.

// hooks/useViewTransition.ts
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';

export function useViewTransition() {
  const router = useRouter();

  const navigate = useCallback(
    (href: string) => {
      if (!document.startViewTransition) {
        // Fallback for Firefox and older browsers
        router.push(href);
        return;
      }

      document.startViewTransition(() => {
        router.push(href);
      });
    },
    [router]
  );

  return { navigate };
}

The CSS side matters just as much as the JS. By default, startViewTransition gives you a 300ms crossfade. You can override it per-element using view-transition-name and the ::view-transition-old / ::view-transition-new pseudo-elements. I've seen teams spend hours perfecting the JS and then ship a clunky transition because they never touched the CSS.

CSS @view-transition for Multi-Page Apps

If you're building a traditional MPA — or a Next.js app using the Pages Router without client-side navigation — you can still get smooth transitions without any JavaScript at all. The @view-transition CSS at-rule, combined with navigation: auto, opts an entire document into cross-document View Transitions.

/* globals.css or your root stylesheet */
@view-transition {
  navigation: auto;
}

/* Customize the outgoing page fade */
::view-transition-old(root) {
  animation-duration: 180ms;
  animation-timing-function: ease-out;
}

/* Customize the incoming page fade */
::view-transition-new(root) {
  animation-duration: 220ms;
  animation-timing-function: ease-in;
}

/* Named transition for a specific hero element */
.hero-image {
  view-transition-name: hero;
}

::view-transition-old(hero),
::view-transition-new(hero) {
  animation-duration: 350ms;
  object-fit: cover;
}

This is surprisingly capable. The browser will automatically match elements with the same view-transition-name across pages and morph between them. It's the same underlying mechanism that powers the shared-element transitions you might have built manually with CSS Houdini Paint Worklets or JavaScript-driven position interpolation.

One gotcha: view-transition-name values must be unique per document at the moment the transition starts. If you have a list of cards and each one gets view-transition-name: card, you'll get undefined behavior. Use the item's ID: view-transition-name: card-42.

Integrating With React Router and Next.js App Router

The App Router in Next.js does client-side navigation by default, which means cross-document View Transitions don't apply — you need the startViewTransition JS approach. The complication is that router.push() in Next.js 15 is asynchronous and returns a promise, but startViewTransition expects a synchronous DOM update callback.

Wrapping navigations in a context provider keeps things clean and avoids scattering startViewTransition calls throughout your component tree. Pair this with the theme toggle patterns you're likely already using to avoid flicker during color-scheme switches — the two interact because View Transitions capture a snapshot of the current paint state, including the CSS custom property values.

For React Router v7, the unstable_viewTransition flag on <Link> components does the heavy lifting automatically. It's been stable in practice for months even with the unstable_ prefix — Meta ships it in production. Just pass viewTransition as a prop and React Router handles the startViewTransition call around its internal state update.

Should you build all this yourself or pull in a library? For most apps, the raw APIs are 30–40 lines of code. Libraries add nice progressive-enhancement logic and handle edge cases like rapid successive navigations, but they also add dependency weight. If you're already thinking about parallax scrolling effects in React and other motion-heavy features, a unified animation library might be worth it. Otherwise, roll it yourself.

Performance Budget and When Paint Holding Fails You

Paint Holding's 500ms budget is generous for fast networks but brutal for slow ones. On a 3G connection, your HTML might not even finish downloading in 500ms. When the timeout expires, the browser shows whatever partial paint it has — which can look worse than the white flash because it's an incomplete layout with images missing.

What does this mean practically? Paint Holding is not a performance fix. It's a polish layer on top of already-fast navigation. If your TTFB is above 300ms, fix that first. Optimize your server response, add edge caching, or prefetch routes on hover. The canvas animation techniques you use for visual flair need to load after critical content, not before it.

There's also the question of whether Paint Holding interacts well with your loading states. If you're showing a skeleton UI on route change, Paint Holding might hold the old page for a moment before switching to the skeleton — which creates a double-transition effect that looks wrong. The fix is to structure your loading states so the skeleton appears within the same first paint frame that Paint Holding eventually releases.

Monitor your Core Web Vitals separately for navigations versus initial loads. INP (Interaction to Next Paint) is the metric most affected by how you handle these transitions. A 200ms Paint Hold that prevents a blank screen is almost always worth it for INP perception, but a 500ms hold that times out is not.

Building a Transition-Aware Navigation Bar in Empire UI

So how do you wire all of this into a real component? The goal is a navigation bar that starts a View Transition on click, shows an immediate visual response (so the user knows something happened), and handles the fallback gracefully when Paint Holding isn't available.

Empire UI's 40 visual styles all rely on CSS custom properties for theming, which makes View Transitions work nicely — the browser snapshots the correct theme state rather than capturing mid-transition colors. Whether you're using the glassmorphism preset (which you can read about in what is glassmorphism) or a flat-design variant, the transition captures the right visual state.

// components/TransitionNav.tsx
'use client';
import { useCallback, useTransition } from 'react';
import { useRouter, usePathname } from 'next/navigation';

interface NavLinkProps {
  href: string;
  children: React.ReactNode;
}

export function TransitionNavLink({ href, children }: NavLinkProps) {
  const router = useRouter();
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();
  const isActive = pathname === href;

  const handleClick = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();

      if ('startViewTransition' in document) {
        document.startViewTransition(() => {
          startTransition(() => {
            router.push(href);
          });
        });
      } else {
        startTransition(() => {
          router.push(href);
        });
      }
    },
    [href, router]
  );

  return (
    <a
      href={href}
      onClick={handleClick}
      aria-current={isActive ? 'page' : undefined}
      data-pending={isPending ? '' : undefined}
      style={{
        // 8px gap between nav items via parent flex container
        opacity: isPending ? 0.6 : 1,
        transition: 'opacity 120ms ease',
      }}
    >
      {children}
    </a>
  );
}

The useTransition hook from React 18+ is doing real work here — it marks the router push as a non-urgent update, so React doesn't block the UI thread. The isPending state gives you an immediate visual acknowledgment (that 0.6 opacity drop) without waiting for the network. Combine this with a view-transition-name on the active indicator bar below the nav items, and you get a sliding underline effect for free.

FAQ

Does Paint Holding work in Firefox?

No. As of late 2026, Paint Holding is a Chromium-only feature. Firefox doesn't implement it, so same-origin navigations in Firefox still show the white flash unless you use the View Transitions API with JavaScript (which Firefox also doesn't fully support yet for cross-document transitions). Your best bet for Firefox is a fast TTFB and skeleton loading states.

Will adding a beforeunload listener break Paint Holding?

Yes, in many cases. Even an empty beforeunload handler can signal to the browser that the page needs special unload handling, which disables Paint Holding. Audit your codebase for any addEventListener('beforeunload', ...) calls — including ones added by analytics libraries. If you only need to track page exits, use the pagehide event instead, which doesn't interfere with Paint Holding.

How do I use view-transition-name with dynamic lists without name collisions?

Use the item's unique ID in the name: view-transition-name: item-${id} set as an inline style. In React, that's style={{ viewTransitionName: item-${item.id} }}. Names must be unique within a single document at transition time. If you're rendering a list of 50 cards all with the same transition name, the behavior is undefined and usually broken.

Can I control the Paint Holding timeout duration?

No. The 500ms timeout is hardcoded in Chromium and not exposed to page authors. There's no meta tag, HTTP header, or JavaScript API to adjust it. If 500ms isn't enough for your page to produce a first paint, the solution is to make your page faster — not to extend the timeout.

What's the difference between startViewTransition and React's useTransition?

They're solving different problems. document.startViewTransition is a browser API that captures screenshots of the DOM before and after a change and crossfades them — it's about visual animation. React's useTransition marks a state update as low-priority so React can interrupt it for more urgent updates — it's about scheduling. You'll often want both: useTransition to keep the UI responsive, and startViewTransition to animate the visual change.

Does the View Transitions API affect my Lighthouse score?

Not directly. The CSS animations from View Transitions happen on the compositor thread and don't block the main thread, so they won't hurt your TBT or TTI scores. However, if you're adding complex ::view-transition animations that run for 500ms+, that can push your LCP measurement later because Lighthouse measures the largest contentful paint across the full page load including transitions.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Speculation Rules API: Instant Page Loads with PrerenderCore Web Vitals in 2026: LCP, INP, CLS with Real Next.js FixesReact Architecture & Patterns: The Complete 2026 GuideParallax Scroll Sections in React: Performance-First Approach