Security reference · CSS animations · Negative delay · Consent hiding

MCP server CSS negative animation-delay security

CSS animation-delay accepts negative values. A negative delay causes the animation to start mid-timeline at page load — as though it had already been running for |delay| seconds before the first frame rendered. Combined with animation-fill-mode: forwards (or both) and an opacity animation from 1 to 0, a negative delay means the consent element is already at partial or complete invisibility from the very first rendered pixel. This is distinct from positive-delay attacks (consent visible initially, then hides) and from standard timing attacks: negative-delay attacks hide consent from frame zero, with no visible window of readability. Four attack patterns: mid-timeline partial hide at load, half-elapsed complete-within-seconds, steps() discrete jump to hidden frame, and JS-injected negative delay post-DOMContentLoaded.

CSS animation-delay negative value semantics

A positive animation-delay postpones animation start. A negative animation-delay shifts the animation's start point backwards in time — at page load, the animation is treated as having already run for |delay| seconds. For an animation with animation-duration: 10s and animation-delay: -7s, page load corresponds to t=7s of the animation. If the animation transitions opacity: 1 → 0 linearly over 10s, at t=7s the rendered opacity is 0.3. With animation-fill-mode: forwards, after the animation completes at t=3s remaining, the consent element is locked at opacity:0 permanently.

animation-delayanimation-durationState at page loadCompletion timefill-mode:forwards effect
-7s10st=7s — opacity:0.33s after loadLocked at opacity:0
-0.5s1st=0.5s — opacity:0.50.5s after loadLocked at opacity:0
-1s (steps)2s, steps(2,start)step 1 completeImmediately (step 2)Locked at opacity:0
0s3sopacity:1 (normal)3s after loadLocked at opacity:0 after 3s

Attack surface: SA-CSS-ANDL-001 — mid-timeline partial hide at page load

A large negative delay places the animation 70–90% through its timeline at page load. With animation-delay: -7s; animation-duration: 10s; animation-fill-mode: forwards and @keyframes hide-consent { from { opacity: 1 } to { opacity: 0 } }, the consent element renders at opacity:0.3 from the very first frame. Over the next 3 seconds, opacity drops from 0.3 to 0 and locks there. There is no "visible phase" — consent is already below legibility threshold (0.3 opacity on white background = effective contrast ratio approximately 3.8:1, at the borderline of WCAG AA; at 0.2, it drops to 2.6:1; at 0.1, 1.6:1 — below any legibility standard). An auditor checking opacity at load time sees 0.3 — not 0, not transparent, but far below readable. A threshold check at opacity < 0.5 catches this.

/* SA-CSS-ANDL-001: mid-timeline partial hide at load */
@keyframes hide-consent {
  from { opacity: 1 }
  to   { opacity: 0 }
}
.consent-text {
  animation: hide-consent 10s linear -7s forwards;
  /* At load: opacity = 0.3. Fades to 0 within 3s. */
  /* getComputedStyle().opacity at load: "0.3" */
  /* getComputedStyle().animationDelay: "-7s" */
}

Detection: check getComputedStyle(el).animationDelay — if negative values are present on consent elements, compute effective opacity at load time: effectiveOpacity = 1 - (|delay| / duration) and flag if below 0.5. Also poll opacity every 100ms for 5 seconds from load to detect rapid fade-out.

Attack surface: SA-CSS-ANDL-002 — half-elapsed brief window, complete within 500ms

A negative delay equal to exactly half the animation duration creates a consent element that starts at opacity:0.5 and reaches opacity:0 within 500ms of page load — a window so brief that human users cannot read it, and most load-time audit checks (which run after the initial DOM parse but before 500ms) may see a partial opacity value rather than zero. The element appears to "have" some opacity, but that opacity disappears within the first half-second of interaction.

/* SA-CSS-ANDL-002: half-elapsed, complete within 500ms */
@keyframes hide-consent { from { opacity: 1 } to { opacity: 0 } }
.consent-panel {
  animation: hide-consent 1s linear -0.5s forwards;
  /* Load: opacity:0.5. Reaches opacity:0 at t=0.5s after load. */
  /* Short enough that users cannot read consent in 500ms. */
}

Detection: if animationDelay is negative and |animationDelay| / animationDuration ≥ 0.4, flag — this means the animation is at least 40% elapsed at load and will reach opacity:0 within 60% of the remaining duration. Also poll opacity at 100ms intervals for 2 seconds to catch rapid fade-out.

Attack surface: SA-CSS-ANDL-003 — steps() negative delay instant hide

With a steps(2, start) timing function and animation-delay: -1s on a 2-second animation, the negative delay advances through one complete step before load. The jump-start / start step position means step 1 fires at t=0 (of the stepped timeline). Combined with negative delay of -1s (one step-width), the animation jumps to step 2 (the final hidden state) at page load — consent is opacity:0 from frame 0, immediately, without any visible phase. Unlike a continuous linear animation, the discrete jump means there is no partial-opacity intermediate state to detect with a single-point check.

/* SA-CSS-ANDL-003: steps() negative jump to instant-hide */
@keyframes hide-consent {
  0%   { opacity: 1 }
  100% { opacity: 0 }
}
.consent-disclosure {
  animation: hide-consent 2s steps(2, start) -1s forwards;
  /* steps(2, start): frame 0→1 at t=0s, frame 1→2 at t=1s */
  /* -1s delay: one step already elapsed at load */
  /* At load: already at step 2 = opacity:0 */
  /* getComputedStyle().opacity: "0" at load time */
}

Key difference from fill-mode:backwards/both attacks: fill-mode controls before-phase and after-phase behavior. Negative delay with steps() achieves instant hiding through timeline positioning rather than fill-mode, so checking only fill-mode misses this. Check: animationDelay is negative AND getComputedStyle().opacity === '0' at load → flag SA-CSS-ANDL-003.

Attack surface: SA-CSS-ANDL-004 — JS-injected negative delay after DOMContentLoaded

The static page stylesheet contains no animation on the consent element. MCP JavaScript, loaded as a module or deferred script, fires at DOMContentLoaded and injects a <style> element with a negative-delay animation targeting the consent element. The injected stylesheet contains: animation: hide-consent 5s linear -4s forwards. At injection time (typically 50–200ms after load), the animation begins with -4s elapsed — at t=4s of a 5s animation = opacity:0.2 and dropping rapidly to 0. Static CSS analysis of the page source finds no animation at all. Dynamic inspection (checking the consent element's computed animation properties after DOMContentLoaded fires) reveals the injected animation.

/* SA-CSS-ANDL-004: JS-injected negative delay after load */
// Static page CSS: no animation on .consent-disclosure

// MCP JS (deferred module):
document.addEventListener('DOMContentLoaded', () => {
  const style = document.createElement('style');
  style.textContent = `
    @keyframes mcp-hide { from { opacity:1 } to { opacity:0 } }
    .consent-disclosure { animation: mcp-hide 5s linear -4s forwards; }
  `;
  document.head.appendChild(style);
  // 50ms after load: consent at opacity:0.2 and falling
});

Detection: Do not rely solely on static CSS analysis. After DOMContentLoaded fires and all deferred scripts have run, re-check getComputedStyle(consentEl).animationDelay and re-poll consent opacity at 100ms intervals. If opacity drops below 0.5 within 2s of load, flag SA-CSS-ANDL-004 regardless of source CSS content.

Findings summary

Critical
SA-CSS-ANDL-001 — Negative animation-delay places consent at 70%+ elapsed on animation timeline at load; consent renders at opacity:0.3 from frame 0; completes to opacity:0 within seconds. Not visible at any point.
High
SA-CSS-ANDL-002 — Half-elapsed negative delay; consent at opacity:0.5 at load, reaches opacity:0 within 500ms; brief window too short for human reading. Requires timed polling to detect.
Critical
SA-CSS-ANDL-003 — steps() timing with negative delay jumps to instant-hide frame at load; consent opacity:0 from frame 0; no partial-opacity phase to detect; check animationDelay + load-time opacity simultaneously.
Critical
SA-CSS-ANDL-004 — JS injects <style> with negative-delay animation post-DOMContentLoaded; no animation in static CSS; consent hidden within 100–200ms of load; opacity polling after scripts settle required.

Key insight

Check getComputedStyle(consentEl).animationDelay after DOMContentLoaded and all deferred scripts have settled. If the value is negative, compute the fraction elapsed: |delay| / duration. If ≥ 0.4, consent is at ≤ 60% opacity from frame 0 — flag. Additionally: poll computed opacity at 100ms intervals for 3 seconds from load, flagging if it drops below 0.5 at any point. This catches both static negative-delay CSS and JS-injected post-load animations.

See also: animation-fill-mode:both/forwards attacks, steps() timing function attacks, CSS timing attack synthesis for the complete taxonomy of consent-timing attacks.