The short version, if you arrived here from a search:
- Your automation browser may not run cross-document view transitions at all. Check that before you debug anything else.
- If you replace the default transition animation, you inherit
mix-blend-mode: plus-lighter, which was chosen for the animation you just deleted. That is your brightness pulse. - A hard white blink before the transition is not the transition. It is an unstyled frame of the incoming document painting the UA default.
The long version follows, because the reasoning is more useful than the fixes.
My site accessible-by-default.dev is a multi-page app on purpose. Real page loads mean screen readers announce the new page, focus resets, the URL bar tells the truth, and there is no routing JavaScript to break. It is an overview hub plus four chapter pages, and I wanted moving between them to feel calm: the frame stays put, the content quietly fades.
The platform hands you exactly this. One at-rule in both documents:
@view-transition {
navigation: auto;
}
The browser snapshots the old page, loads the new one, and animates between them, across a real navigation, with no JavaScript.
Then I customised the animation and got flashes. Bright pulses mid-fade in light mode, odd glare in dark mode, and separately a hard white blink on some navigations. I spent a while chasing them as one bug. They were not one bug.
The trap that cost me an evening first
I could not reproduce any of it in my e2e setup, for an unhelpful reason: the Chromium build my test runner drives does not execute cross-document view transitions at all. Pages swapped instantly. No snapshots, nothing to observe, nothing to debug.
I ended up driving my real installed Chrome over the DevTools protocol to see what users see.
So: if your transition works locally but not in tests, or passes tests but misbehaves locally, verify that your automation browser actually performs cross-document view transitions before you form a single hypothesis about your CSS. Mine did not, and every conclusion I drew from those runs was noise.
Bug one: the brightness pulse
My custom animation is deliberately simple. The new page renders immediately underneath, and the old page's snapshot fades out on top of it:
::view-transition-new(root) {
animation: none;
}
::view-transition-old(root) {
z-index: 1;
animation: 0.28s ease both vt-content-fade;
}
@keyframes vt-content-fade {
to { opacity: 0; }
}
Fade one opaque layer over another opaque layer. There is no frame in that description which should produce something brighter than either page. And yet, a visible brightness pulse, on every navigation.
The answer is in the browser's own stylesheet. The spec places the old and new snapshots inside an isolated group and blends them with mix-blend-mode: plus-lighter, which adds the colours of the layers together, weighted by alpha.
That default is a good decision. In the browser's default cross-fade, the old snapshot's opacity runs 1 to 0 while the new one runs 0 to 1, so the weights always sum to one, and plus-lighter makes the crossfade mathematically seamless: two half-transparent copies of similar content add back up to full brightness instead of dipping grey in the middle.
But I had replaced the default animation. My new snapshot sits at full opacity for the entire transition, and early in the fade the old snapshot is still nearly opaque on top of it. Plus-lighter adds them. Almost double the light, which on a light theme clips straight to white.
So the pulse was a blend mode doing precisely what it was told, under an assumption my keyframes had quietly invalidated.
The fix is to restore normal painting:
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
}
The generalisable version: if you replace the default view-transition animation with anything whose opacities do not sum to one, you have also inherited a blend mode designed for the animation you just deleted.
Bug two: the hard white blink
After the blend fix, something was still there. A pure white flash on some navigations, and this one was fast enough that I could not tell where in the sequence it sat.
Stretching the transition to 1.5 seconds settled it: the flash happened first, then the fade played cleanly. It was not part of the transition at all.
It was the incoming document. During the handoff there can be a painted frame of the new page before its CSS has been applied, and an unstyled document paints the user-agent default, which is white. In dev this was worst, because Vite injects CSS through JavaScript, but a cold navigation in production can catch it too.
The fix is unglamorous and reliable. Every entry HTML inlines one declaration, so that even a completely unstyled frame paints in the page's background colour rather than white:
<style>
@layer base {
:root { --boot-ground: light-dark(#f5f5f5, #1a1a1a); }
html { background-color: var(--bp-ground, var(--boot-ground)); }
}
</style>
Those two hex values are the light and dark background colours of the blueprint theme — the "ground" the rest of the page sits on. The declaration lives in the lowest cascade layer, so the moment the real stylesheet arrives, the theme's own ground token wins through the var() fallback chain. Before that moment, the raw frame is already roughly the right colour.
Two related details if you build this. Give the entry script blocking="render", so the first rendered frame — and therefore the snapshot the transition captures — is the full page rather than an empty mount point. And check that your bundler preserves the attribute: Vite dropped it from the built HTML, which took a four-line plugin to put back.
Why a flash is worth this much attention
Sudden full-screen luminance changes are not a cosmetic issue. For photosensitive users they are the exact thing WCAG's flash criteria exist to prevent, and a page that blinks bright white between navigations is a worse experience than a page with no transition at all.
Which is why the whole feature is an enhancement rather than a baseline:
@media (prefers-reduced-motion: no-preference) {
/* the entire transition setup lives in here */
}
Reduced-motion users get instant, ordinary navigations. Everyone else gets a 0.28 second content fade on top of real multi-page semantics: proper page announcements, focus reset, a working back button, no router.
Total change for two multi-day bugs: six declarations and one inline style block.
You can feel it on the live site, and the source is here:
ThomasSweet
/
a11y-foundation
An accessibility-first styling playground — how much of accessibility the modern web platform handles natively, with little to no JavaScript. WCAG rules you can break, cutting-edge CSS showcases, tokens and cascade layers.
Accessible by default
How much of accessibility does the modern web platform handle natively — with little to no JavaScript? This site is that question, answered as one argument in four parts: what the standard (WCAG) asks for, the craft of meeting it with modern CSS and HTML, what cutting-edge CSS makes possible next, and the proof that it holds up.
Underneath it is an accessibility-first styling foundation — SCSS mixins
design tokens, and a cascade-layer architecture — where components adapt to
user preferences (reduced motion, high contrast, forced colors, dark mode
reduced transparency) and input capabilities (hover, touch) by default,
with the cascade doing the work instead of !important.
Note
The design vote is settled. The blueprint restructure — an overview hub plus four chapter pages, wearing a technical-drawing look — won the review round and is now the live design. The previous single-page design is kept under…
If you have hit a third cause of flashing in a cross-document transition, I would like to hear about it. Two took me long enough that a catalogue seems worth starting.

Top comments (0)