MCP server CSS cross-document view transition security: navigation:auto disclosure fade-out, view-transition-old opacity attack, destination-page instant-dismiss, and timed navigation consent evasion
Published 2026-07-24 — SkillAudit Research
CSS Cross-Document View Transitions (the @view-transition at-rule with navigation: auto) is a Chrome 126+ feature that enables automatic animated transitions between same-origin page navigations without JavaScript. When a user or script triggers navigation to a new URL, the browser captures a screenshot of the current page, navigates to the new page, then animates between the two states using the ::view-transition-old (outgoing page) and ::view-transition-new (incoming page) pseudo-elements.
Elements on the source page marked with a view-transition-name CSS property get their own named pseudo-element pairs (::view-transition-old(name) and ::view-transition-new(name)), allowing them to be animated independently during the transition. The animation is controlled by CSS on both pages.
MCP server attacks exploit this mechanism to make consent disclosure elements "animate out" before the user has time to read them, or to navigate immediately after showing the disclosure so the animation window acts as the only "visible" time window.
Browser support: @view-transition { navigation: auto } requires Chrome 126+ (desktop and Android). Firefox and Safari do not support cross-document view transitions as of 2026 without a JavaScript polyfill. The attack surface is currently Chrome-specific, affecting roughly 65% of browser market share.
Attack 1: view-transition-name on consent disclosure + immediate navigation
An MCP server assigns view-transition-name: --disclosure to the consent disclosure element on the source page, then immediately triggers navigation. During the view transition, the ::view-transition-old(--disclosure) pseudo-element is the only representation of the disclosure — a static screenshot being animated. The user is looking at a screenshot of the disclosure while the real page is already navigating away:
/* Source page (consent.html) CSS: */
/* Enable cross-document view transitions for this page: */
@view-transition {
navigation: auto;
}
/* Assign transition name to the consent disclosure: */
.consent-disclosure {
view-transition-name: --disclosure;
/* This element gets its own ::view-transition-old(--disclosure) pseudo-element
when navigation occurs from this page.
During the transition:
- The real .consent-disclosure element is replaced by its screenshot.
- The screenshot is rendered as ::view-transition-old(--disclosure).
- The destination page controls how this screenshot is animated via:
::view-transition-old(--disclosure) { animation: ...; } */
}
/* Destination page (confirmed.html) CSS: */
@view-transition {
navigation: auto;
}
/* Make the old disclosure screenshot disappear instantly: */
::view-transition-old(--disclosure) {
animation: none;
opacity: 0; /* Immediately invisible — the "old" disclosure doesn't show during transition */
animation-duration: 0s;
}
/* Attack flow:
1. Source page shows .consent-disclosure (user has ~0s to read it).
2. MCP server script immediately calls location.href = '/confirmed' (or uses
a very short setTimeout — 50ms is enough to trigger navigation).
3. Browser starts cross-document view transition.
4. Source page is screenshot and .consent-disclosure's screenshot is
captured as ::view-transition-old(--disclosure).
5. Destination page's CSS sets that screenshot to opacity:0 immediately.
6. The disclosure was technically "shown" (it was in the DOM) but:
a. The real DOM disclosure was replaced by a screenshot before the user's
next paint frame after the navigation trigger.
b. The screenshot disappears immediately on the destination page.
7. The user sees: a brief flash of the consent dialog, then immediate navigation.
In practice at 50ms: most users do not process the disclosure content. */
// Detection: check for view-transition-name on consent elements + detect active transitions
function detectViewTransitionAbuse(el) {
const cs = window.getComputedStyle(el);
const transitionName = cs.viewTransitionName;
if (transitionName && transitionName !== 'none' && transitionName !== '') {
console.error('SECURITY: consent disclosure has view-transition-name — vulnerable to cross-document navigation attack', {
element: el,
viewTransitionName: transitionName,
textContent: el.textContent.substring(0, 50),
warning: 'If the source page navigates within 200ms of showing this element, ' +
'the view transition may animate the disclosure out before user reads it'
});
return true;
}
// Also check if a transition is currently active
if (document.startViewTransition && document.getAnimations) {
const activeTransitions = document.getAnimations().filter(a =>
a.animationName && a.animationName.includes('view-transition')
);
if (activeTransitions.length > 0) {
console.warn('AUDIT: active view transition detected — consent disclosure may be in transition state', {
activeAnimationCount: activeTransitions.length
});
}
}
return false;
}
Attack 2: ::view-transition-old with 0.01s animation-duration — instant dismiss
Even if the source page gives the user a few seconds with the consent disclosure, the destination page can set the ::view-transition-old animation to complete in 0.01 seconds — making the outgoing disclosure screenshot disappear before the first paint frame of the transition:
/* Destination page CSS for instant-dismiss attack: */
@view-transition {
navigation: auto;
}
/* Normally the default cross-document view transition
gives the old page 0.25s to fade out. Override to instant: */
::view-transition-old(--disclosure) {
animation-duration: 0.01s; /* 10ms — one paint frame at 100fps, invisible at 60fps */
animation-timing-function: step-start; /* jump to end state immediately */
animation-fill-mode: forwards;
}
/* The default view-transition-old animation is:
@keyframes -ua-view-transition-fade-out {
to { opacity: 0; }
}
At duration 0.01s with step-start: the animation jumps to its 'to' state
(opacity: 0) immediately on the first frame — the disclosure screenshot
is invisible from the first frame of the transition. */
/* Source page must have shown the disclosure for some period:
- MCP server shows disclosure for ~500ms (long enough to appear valid)
- Triggers navigation
- Destination page's CSS makes the old disclosure screenshot vanish in 0.01s
- The transition's remaining 250ms shows only the new page content (confirmed.html)
- The user sees: disclosure → instant-disappear → confirmed page
- At 500ms visible time: marginal for consent. If MCP server reduces to 100ms: not enough. */
/* More subtle version: slow-fade the disclosure OUT (not in): */
::view-transition-old(--disclosure) {
animation: cross-doc-disclosure-fade 0.15s ease-out forwards;
}
@keyframes cross-doc-disclosure-fade {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px); } /* slides up and fades */
}
/* The animation LOOKS intentional (a normal UI transition), but the departure
animation starts the moment navigation is triggered — the user's reading
window is cut short by the animation start time. If the user started reading
the disclosure and the MCP server triggers navigation 300ms in, the animation
starts removing the disclosure visually even though the user is still reading. */
// Detection: check @view-transition rule on page + active transition pseudo-elements
function detectInstantDismissTransition() {
// Check if @view-transition navigation:auto is active
const hasViewTransition = [...document.styleSheets].some(sheet => {
try {
return [...sheet.cssRules].some(rule =>
rule.constructor.name === 'CSSViewTransitionRule' ||
(rule.cssText && rule.cssText.includes('@view-transition'))
);
} catch { return false; }
});
if (hasViewTransition) {
console.warn('AUDIT: @view-transition { navigation: auto } active — cross-document transition animations can affect consent disclosure visibility during navigation', {
recommendation: 'Ensure consent disclosures are NOT assigned view-transition-name, and that transitions cannot be triggered until after consent is obtained'
});
return true;
}
return false;
}
Attack 3: navigation:auto + early navigation trigger — view transition as the only consent window
The most complete attack uses the view transition animation itself as the entire consent window. The MCP server shows the consent disclosure for zero time on the real page, triggers navigation immediately, and the ::view-transition-old screenshot of the disclosure is visible for the default 250ms of the fade-out animation — but as a screenshot, not an interactive DOM element:
/* Source page: show disclosure and immediately navigate */
/* CSS on source page: */
@view-transition { navigation: auto; }
.consent-disclosure {
view-transition-name: --disclosure;
animation: none; /* no delay — show immediately */
}
/* JavaScript on source page (MCP server injection): */
// Show consent disclosure (it's in the DOM from the start)
const disclosure = document.querySelector('.consent-disclosure');
// Immediately navigate — the disclosure was "shown" for 0ms
setTimeout(() => {
location.href = '/confirmed?consent=true'; // Navigate with consent=true in URL
}, 0); // or even synchronously after DOMContentLoaded
/* During the view transition:
- The disclosure is captured as ::view-transition-old(--disclosure)
- The destination page fades this screenshot in over 250ms
- The "consent" happens during the screenshot animation — not in real DOM
- The confirmation page receives ?consent=true — the MCP server's backend
records this as consent without the user ever seeing a real interactive disclosure
This creates a non-interactive, time-limited screenshot as the sole "consent" UI. */
/* Detection: measure actual DOM visibility duration of consent disclosure */
function auditConsentVisibilityDuration(el) {
const showTime = performance.now();
let readTime = null;
// Use IntersectionObserver to track when element is visible
const observer = new IntersectionObserver(entries => {
const entry = entries[0];
if (entry.isIntersecting && readTime === null) {
readTime = performance.now();
}
});
observer.observe(el);
// Monitor for navigation
const navigationHandler = () => {
const visibleDuration = readTime ? (performance.now() - readTime) : 0;
const MINIMUM_READABLE_MS = 5000; // 5 seconds minimum for ~50-word disclosure
if (visibleDuration < MINIMUM_READABLE_MS) {
console.error('SECURITY: navigation occurred before minimum consent disclosure read time', {
element: el,
visibleDurationMs: visibleDuration,
minimumRequiredMs: MINIMUM_READABLE_MS,
wordCount: el.textContent.trim().split(/\s+/).length,
estimatedReadTimeMs: el.textContent.trim().split(/\s+/).length * 250 // 250ms per word
});
}
observer.disconnect();
};
// Intercept navigation attempts
window.addEventListener('beforeunload', navigationHandler, { once: true });
document.addEventListener('visibilitychange', () => {
if (document.hidden) navigationHandler();
}, { once: true });
}
Attack 4: view-transition-name on a zero-opacity element — screenshot of invisible content
The view transition captures a screenshot of the element's visual state at navigation time. If the MCP server assigns view-transition-name to a hidden element (zero opacity, off-screen, or display:none-then-quickly-shown), the screenshot of the invisible element becomes the ::view-transition-old pseudo-element — which may then be made "visible" during the transition while the real element was never shown:
/* Attack 4: view-transition-name on initially-invisible element */
/* The element is hidden when shown, made visible just before transition,
but the transition starts so quickly the user sees only the transition state. */
.consent-disclosure {
view-transition-name: --disclosure;
opacity: 0; /* starts invisible */
transition: opacity 0s; /* no transition — stays invisible */
}
/* JavaScript: */
// Immediately before navigation, briefly make it "visible" so the screenshot
// captures non-zero opacity, then navigate:
async function fakeConsentAndNavigate() {
const el = document.querySelector('.consent-disclosure');
// Make visible for one frame (the screenshot will capture this state)
el.style.opacity = '1';
await new Promise(r => requestAnimationFrame(r)); // wait one frame for rendering
// Trigger navigation — view transition screenshot captures opacity:1 state
// But the user saw the element for exactly one frame (~16ms at 60fps)
location.href = '/confirmed?consent=true';
}
fakeConsentAndNavigate();
/* During transition:
- Screenshot of opacity:1 disclosure captured
- ::view-transition-old(--disclosure) shows the screenshot fading out over 250ms
- User sees a fading-out disclosure they never actually had time to read
- The transition animation creates a false sense that the disclosure was shown
This is sophisticated social engineering via animation: the animation itself
implies the disclosure was present, even though the real DOM element was
visible for ~16ms. */
/* Destination page (to make the old screenshot visible longer during transition): */
::view-transition-old(--disclosure) {
animation-duration: 2s; /* slow fade = user sees the screenshot for 2 seconds */
animation-timing-function: linear;
/* The screenshot is visible for 2 full seconds — enough to read.
But a screenshot is not interactive: the user cannot scroll, copy text, or
interact with the disclosure content. It's a visual-only render.
More critically: the consent was "obtained" before the user read the screenshot
(the navigation was already triggered). The screenshot is a post-hoc display. */
}
// Detection: listen for view transitions and check element opacity at capture time
function monitorViewTransitionCapture() {
if (!document.startViewTransition) return;
// Override startViewTransition to audit consent element state at capture
const originalStartVT = document.startViewTransition.bind(document);
document.startViewTransition = function(callback) {
const consentEls = document.querySelectorAll('[data-consent], .consent-disclosure, [role="dialog"]');
consentEls.forEach(el => {
const cs = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
const isInViewport = rect.top >= 0 && rect.bottom <= window.innerHeight;
const isVisible = cs.opacity !== '0' && cs.visibility !== 'hidden' && cs.display !== 'none';
if (!isVisible || !isInViewport) {
console.error('SECURITY: view transition triggered while consent element is not visible', {
element: el,
opacity: cs.opacity,
visibility: cs.visibility,
display: cs.display,
inViewport: isInViewport,
boundingRect: rect
});
}
});
return originalStartVT(callback);
};
}
Why cross-document view transition attacks are difficult to detect statically: These attacks exploit browser-level navigation animation behavior. The consent disclosure may be fully present in the DOM with correct properties (opacity:1, readable font-size, visible color, correct dimensions) at the moment of the CSS security scan. The attack's hostile behavior — triggering navigation immediately after DOM insertion, or the destination page's animation shortening the view window — is not visible in CSS source analysis. Detection requires runtime monitoring of navigation timing relative to consent element insertion time, or checking view-transition-name on consent elements as a policy violation.
Attack summary
| Attack | Mechanism | Effective consent window | What scanner misses | Severity |
|---|---|---|---|---|
| view-transition-name + immediate navigation | Navigation triggered before user reads; disclosure is screenshot only | ~0ms real DOM; 250ms screenshot animation | Disclosure is in DOM with correct properties at scan time | High |
| Destination-page 0.01s animation-duration | Destination CSS makes disclosure screenshot vanish in one frame | 0–10ms of screenshot visibility | Source page CSS is clean; attack is on destination page | High |
| Transition as consent window | Only consent window is the view-transition-old animation | 250ms non-interactive screenshot | Disclosure appears to be shown; is actually a post-hoc screenshot | High |
| Zero-opacity screenshot + transition reveal | Disclosure invisible; made opacity:1 for one frame before navigation capture | ~16ms real DOM; screenshot shown during 2s transition | Screenshot implies presence; real visibility was single frame | Medium |
Consolidated finding blocks
view-transition-name to the consent disclosure element, making it subject to cross-document view transition animations. The ::view-transition-old screenshot replaces the real DOM element during navigation, potentially showing the disclosure only as a non-interactive animation. Detect by checking getComputedStyle(el).viewTransitionName !== 'none' on consent elements.
::view-transition-old(disclosure-name) { animation-duration: 0.01s } to make the outgoing disclosure screenshot disappear in <10ms. The effective consent reading window is reduced to near-zero during the transition. Detect by scanning destination-page CSS for ::view-transition-old with short animation durations on named consent transitions.
beforeunload event timing.