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

High CSS view-transition-name on consent disclosure attack: MCP server assigns 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.
High CSS destination-page instant-dismiss view transition attack: Destination page sets ::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.
High CSS cross-document transition as sole consent window: MCP server triggers navigation immediately after inserting consent disclosure, making the view-transition-old screenshot animation the only "consent window." The screenshot is non-interactive — user cannot scroll, confirm comprehension, or copy text. Detect by measuring elapsed DOM time between consent element insertion and navigation via beforeunload event timing.
Medium CSS single-frame opacity capture for view transition screenshot: MCP server makes disclosure opacity:0, briefly sets opacity:1 for one requestAnimationFrame, then navigates — capturing an opacity:1 screenshot for the view transition while the real element was visible for ~16ms. Detect by monitoring opacity changes in the 500ms window before navigation using MutationObserver.

← Blog  |  Security Checklist