Security Research · September 5, 2026

CSS Animation Sub-Properties as MCP Consent Bypass: animation-play-state, animation-direction, and animation-iteration-count

CSS animations are the standard technique for revealing consent dialogs and approve buttons with smooth transitions. The shorthand animation property compiles into nine individual sub-properties — and static audits that verify "the animation is declared correctly" typically read the shorthand values or check that animation-name and animation-duration are set. Three of those sub-properties — animation-play-state, animation-direction, and animation-iteration-count — each independently prevent a consent button from ever reaching its visible state while the animation declaration appears fully correct. This article synthesizes the full attack surface, explains four patterns per property, and provides a consolidated ConsentAnimationAudit class for runtime detection.

Why animation sub-properties create an audit blind spot

The CSS animation shorthand parses into eight longhand sub-properties: animation-name, animation-duration, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, animation-fill-mode, and animation-play-state. Auditors who verify that a consent button has a properly configured animation typically check the shorthand value or the most visible sub-properties: does the element have an animation-name? Is the duration non-zero? These checks confirm that an animation is attached — not that it will ever reach a visible keyframe.

The three sub-properties covered here operate on the animation's progress rather than its presence:

Each of these can be set in a sub-property declaration that overrides the shorthand, by a higher-specificity rule, by a JS style.setProperty call during mousedown, or through a CSS custom property. All three are classified as detached from "animation presence" checks — a static audit confirms the animation exists, not that it runs correctly or reaches a visible state.

The core audit gap: Confirming that animation-name and animation-duration are set does not confirm that the animation progresses. A consent button with animation: reveal-btn 0.4s ease forwards overridden by animation-play-state: paused has a fully correct animation declaration — and a permanently invisible button.

The three attack sub-properties

animation-play-state

Values: running (default) and paused. Paused stops the animation at its current keyframe. If paused at t=0 before any keyframe runs, the element holds its initial styles — typically the invisible reveal-start state.

Attack: freeze at invisible start frame; pass animation-presence checks; no JS needed

animation-direction

Values: normal, reverse, alternate, alternate-reverse. Reverse plays keyframes from 100% to 0%. A reveal animation's keyframes run backwards — from visible to invisible. With fill-mode: forwards, the element locks at the invisible 0% frame permanently.

Attack: invert reveal to hide; fill-mode locks element at invisible initial keyframe

animation-iteration-count

Values: a non-negative number or infinite. A value of 0 plays the animation zero times — no keyframe ever applies. A fractional value like 0.5 stops at 50% of the cycle. fill-mode does not apply when iteration count is zero.

Attack: zero count skips all keyframes; fractional count stops at low-opacity midpoint; infinite with mostly-invisible cycle

Attack surface 1: animation-play-state — button frozen at invisible start frame

The consent dialog uses a @keyframes animation to reveal the approve button from an invisible initial state (opacity: 0, off-screen) to its visible position. A single animation-play-state: paused declaration on the button — placed after the shorthand in the cascade, or on a higher-specificity selector — prevents the animation from ever leaving its start frame. The element's computed opacity remains 0, its computed transform remains at the off-screen initial value, and its display is not none. All naive visibility checks pass.

The four attack patterns for this property are documented fully in the animation-play-state security guide. The key patterns are: (1) a CSS-only static paused declaration; (2) a hover/focus-conditional override that removes running state at mousedown; (3) a JS mousedown injection that sets paused on the inline style mid-animation; and (4) a parent-level cascade where the pause originates in an ancestor element or a CSS custom property rather than on the button itself.

/* Example: sub-property override after shorthand declaration */
.approve-btn {
  animation: reveal-button 0.4s ease forwards; /* shorthand — animation declared correctly */
  animation-play-state: paused;               /* sub-property override — animation frozen at t=0 */
}

/* Audit check for animation-name ✓, animation-duration ✓, animation-fill-mode ✓
   Audit does NOT check animation-play-state → button permanently at opacity:0 */

getBoundingClientRect still returns non-zero dimensions. A paused animation at t=0 with transform: translateY(40px) still participates in layout — it is not display: none. The BCR check returns a box with dimensions, simply offset from the expected position. BCR-in-viewport checks do not catch this attack without also reading the computed opacity.

Attack surface 2: animation-direction — reveal plays as hide

animation-direction: reverse inverts the playback order of the @keyframes rule without changing any other property. A keyframe declared as from { opacity: 0; transform: translateY(40px) } to { opacity: 1; transform: translateY(0) } runs backwards under reverse: the animation starts at opacity: 1 and hides the button to opacity: 0 and off-screen. With animation-fill-mode: forwards, the element locks at its final frame — the invisible 100%-time-of-reverse frame, which is the from block of the original keyframes.

The full attack surface with all four patterns is covered in the animation-direction security guide. Beyond simple reverse, the alternate-reverse value creates a particularly deceptive pattern: with two iterations, the first pass runs from 100%→0% (hides the button), and the second pass runs from 0%→100% (reveals it). The button is briefly accessible at exactly the midpoint — which can be milliseconds with a short duration — but spends the first half of the animation invisible.

/* Attack: direction:reverse turns a reveal keyframe into a hide */
@keyframes reveal-button {
  from { opacity: 0; transform: translateY(40px); }  /* initial invisible state */
  to   { opacity: 1; transform: translateY(0); }     /* visible target state */
}

.approve-btn {
  animation: reveal-button 0.4s ease forwards;
  animation-direction: reverse;
  /* Playback: starts at 'to' frame (opacity:1, visible), ends at 'from' frame (opacity:0, off-screen).
     fill-mode:forwards locks element at 'from' frame — permanently invisible. */
}

/* Audit: animation-name ✓, animation-duration ✓, keyframes defined ✓
   Audit does NOT check animation-direction → keyframes play backwards → hide animation */
// Detection: check computed animation-direction
function auditAnimationDirection(el) {
  const cs = getComputedStyle(el);
  const dir = cs.getPropertyValue('animation-direction');
  const dangerValues = ['reverse', 'alternate-reverse'];
  const parts = dir.split(',').map(s => s.trim());
  for (const part of parts) {
    if (dangerValues.includes(part)) {
      console.warn('[SkillAudit] animation-direction:', part,
        '— keyframes play backwards; reveal animation becomes hide:', el);
    }
  }
}

Attack surface 3: animation-iteration-count — animation never runs

animation-iteration-count: 0 specifies that the animation plays zero times. This is not an error — it is a valid CSS value. The browser creates the animation, resolves the keyframes, and sets the element's animation active time to zero. No keyframe styles ever apply. Critically, animation-fill-mode does not apply when iteration count is zero: even fill-mode: forwards does not hold the final keyframe if the animation never played. The element holds its pre-animation initial CSS values — typically the invisible starting state.

The full attack surface for this property is documented in the animation-iteration-count security guide. The fractional variant (0.5 iterations) is particularly subtle: the animation plays through 50% of the cycle, stopping at the midpoint. If the timing function is ease-in, the actual opacity at 50% time is typically 10–20% of the full range — effectively invisible but not zero. A static check that sees animation-iteration-count: 0.5 must also evaluate the animation's timing function and opacity curve to assess the actual visible state at the 50% mark.

/* Attack: iteration-count:0 — animation plays zero times */
.approve-btn {
  animation: reveal-button 0.4s ease forwards;
  animation-iteration-count: 0;
  /* No keyframe ever applies. fill-mode:forwards does not apply at count:0.
     Element holds initial CSS: opacity:0 (from stylesheet, not from keyframe).
     Computed animation-iteration-count is 0 — but this is a legal value. */
}

/* Subtle variant: fractional count stops at low-opacity midpoint */
.approve-btn {
  animation: reveal-button 3s ease-in forwards;
  animation-iteration-count: 0.5;
  /* 0.5 = plays through 1.5s of the 3s animation.
     ease-in: opacity at t=1.5s ≈ 0.12 (12% of 0→1 range).
     Button is at 12% opacity — invisible but not zero. fill-mode holds at 12%. */
}

The fill-mode exception: The CSS specification explicitly states that animation-fill-mode does not produce filled values when animation-iteration-count is zero. An auditor who sees fill-mode: forwards and assumes "the final keyframe will always be applied" is wrong when iteration-count: 0 is also set. These two sub-properties must be checked together.

Combined attack: three sub-properties together

Each sub-property is independently sufficient to prevent button visibility. Combined, they provide redundant hiding with multiple fallback mechanisms. A sophisticated MCP server might deploy all three:

/* Combined attack: all three sub-properties applied simultaneously */
.approve-btn {
  /* Base animation shorthand — appears correctly configured */
  animation: reveal-button 2s ease forwards;

  /* Sub-property 1: paused — frozen at t=0 invisible frame */
  animation-play-state: paused;

  /* Sub-property 2: reverse — if play-state is ever set to running, plays as hide */
  animation-direction: reverse;

  /* Sub-property 3: count 0.5 — if somehow running forward, stops at ~10% opacity */
  animation-iteration-count: 0.5;
}

/* Detection ladder:
   - If paused is fixed first: direction:reverse means running plays as hide anyway.
   - If direction is fixed second: count:0.5 means only 50% of a hide animation runs.
   - Any single-property fix leaves two other attack vectors in place.
   - All three must be validated simultaneously. */

The combined pattern also provides deniability. Each sub-property has a plausible standalone justification: paused might be set to allow user-triggered play, reverse might appear to be a CSS-order mistake, and a fractional iteration count might be explained as an "ease-in effect." An auditor must evaluate the interaction of all three to understand the net effect on button visibility.

JS mousedown injection patterns

All three sub-properties can be injected dynamically during mousedown — the event that fires before click and before the browser confirms a clickable target. A mousedown listener on the document sets one or more sub-properties on the consent button's inline style, changes the button's state to invisible, and the click fires on an element that is no longer visible.

// Mousedown injection: freeze animation mid-reveal using play-state
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (btn) {
    // At mousedown, animation may be at any frame. Pausing here freezes it.
    // If the animation is early (low opacity), button is now frozen invisible.
    btn.style.setProperty('animation-play-state', 'paused');
  }
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (btn) btn.style.removeProperty('animation-play-state');
  // Animation resumes, but click has already fired on invisible element.
});
// Detection: MutationObserver watching for style changes during mousedown
const mousedownActive = { value: false };
document.addEventListener('mousedown', () => { mousedownActive.value = true; }, true);
document.addEventListener('mouseup',   () => { mousedownActive.value = false; }, true);

const WATCHED_PROPS = ['animation-play-state', 'animation-direction', 'animation-iteration-count'];

const observer = new MutationObserver(mutations => {
  if (!mousedownActive.value) return;
  for (const m of mutations) {
    if (m.type !== 'attributes' || m.attributeName !== 'style') continue;
    const el = m.target;
    for (const prop of WATCHED_PROPS) {
      const val = el.style.getPropertyValue(prop);
      if (val) {
        console.warn('[SkillAudit] animation sub-property injected during mousedown:',
          prop, '=', val, el);
      }
    }
  }
});

document.querySelectorAll('.consent-dialog, .approve-btn').forEach(el =>
  observer.observe(el, { attributes: true, subtree: true })
);

Consolidated ConsentAnimationAudit class

The following class performs a comprehensive static check of all three sub-properties on a consent element, evaluates the combined interaction, and reports specific findings with context. It should be run at the point in the consent flow where the button is expected to be visible.

class ConsentAnimationAudit {
  constructor(el) {
    this.el = el;
    this.cs = getComputedStyle(el);
    this.findings = [];
  }

  run() {
    this._checkPlayState();
    this._checkDirection();
    this._checkIterationCount();
    this._checkInteraction();
    return this.findings;
  }

  _checkPlayState() {
    const val = this.cs.getPropertyValue('animation-play-state');
    const states = val.split(',').map(s => s.trim());
    if (states.some(s => s === 'paused')) {
      this.findings.push({
        severity: 'high',
        property: 'animation-play-state',
        value: val,
        detail: 'Animation is paused. Element is frozen at its current keyframe. ' +
          'If paused before any keyframe runs (at t=0), element is at its initial invisible state.'
      });
    }
  }

  _checkDirection() {
    const val = this.cs.getPropertyValue('animation-direction');
    const dangerous = ['reverse', 'alternate-reverse'];
    const dirs = val.split(',').map(s => s.trim());
    const hits = dirs.filter(d => dangerous.includes(d));
    if (hits.length > 0) {
      this.findings.push({
        severity: 'high',
        property: 'animation-direction',
        value: val,
        detail: 'Animation direction includes: ' + hits.join(', ') + '. ' +
          'Keyframes play backwards — a reveal animation becomes a hide animation. ' +
          'With fill-mode:forwards, element locks at its invisible initial keyframe.'
      });
    }
  }

  _checkIterationCount() {
    const val = this.cs.getPropertyValue('animation-iteration-count');
    const counts = val.split(',').map(s => s.trim());
    for (const c of counts) {
      const n = parseFloat(c);
      if (n === 0) {
        this.findings.push({
          severity: 'high',
          property: 'animation-iteration-count',
          value: c,
          detail: 'Iteration count is 0. Animation plays zero times. No keyframe styles apply. ' +
            'fill-mode has no effect at count:0. Element holds initial invisible CSS values.'
        });
      } else if (!isNaN(n) && n < 1) {
        this.findings.push({
          severity: 'medium',
          property: 'animation-iteration-count',
          value: c,
          detail: 'Fractional iteration count (' + c + '). Animation stops at ' + (n * 100) + '% of cycle. ' +
            'Actual visible opacity depends on timing function — ease-in puts most progress near end.'
        });
      } else if (c === 'infinite') {
        // Not inherently dangerous but check computed opacity
        const opacity = parseFloat(this.cs.getPropertyValue('opacity'));
        if (opacity < 0.1) {
          this.findings.push({
            severity: 'medium',
            property: 'animation-iteration-count',
            value: 'infinite',
            detail: 'Infinite iteration count with computed opacity < 0.1. ' +
              'Element may be in the hidden portion of an alternating animation cycle.'
          });
        }
      }
    }
  }

  _checkInteraction() {
    if (this.findings.length >= 2) {
      this.findings.push({
        severity: 'critical',
        property: 'animation (combined)',
        value: '—',
        detail: 'Multiple animation sub-property attacks detected simultaneously. ' +
          'Fixing one sub-property leaves the others in place as fallback hide vectors. ' +
          'All three — play-state, direction, iteration-count — must be validated together.'
      });
    }
  }
}

Detection gap analysis

Property Attack vector Detection requires Button state
animation-play-state CSS-only paused via sub-property override Read computed animation-play-state (not just shorthand) Invisible, never revealed
animation-play-state JS mousedown injects paused mid-animation MutationObserver during mousedown window Frozen at current frame (early = invisible)
animation-direction reverse inverts reveal to hide; fill-mode:forwards locks at invisible frame Check computed animation-direction and animation-fill-mode together Permanently invisible after animation completes
animation-direction alternate-reverse with 2 iterations — hides first, briefly reveals, hides again Check direction + iteration-count + timing to evaluate visibility window Briefly visible at midpoint (~1ms with short duration)
animation-iteration-count 0 — animation never plays; initial CSS holds Check computed animation-iteration-count; note fill-mode exception at count 0 Permanently invisible (initial CSS value)
animation-iteration-count Fractional count (0.5) stops animation at low-opacity midpoint Evaluate opacity at N×cycle with actual timing function curve Near-invisible (~10% opacity at midpoint with ease-in)
All three combined play-state + direction + count used simultaneously as redundant hide layers All three sub-properties must be checked simultaneously; single-property fix leaves fallbacks Multiply redundant invisible state

Findings summary

High animation-play-state: paused set on consent button — animation frozen at t=0 invisible start frame; button has valid BCR and display; only computed play-state or direct opacity read detects attack; static analysis of animation shorthand misses sub-property override.
High animation-direction: reverse on reveal animation — keyframes play 100%→0%; reveal becomes hide; animation-fill-mode: forwards locks button at invisible initial keyframe permanently; animation appears fully declared and syntactically correct.
High animation-iteration-count: 0 on reveal animation — animation plays zero times; no keyframe ever applies; fill-mode exception at count 0 means forwards does not hold final keyframe; element stays at initial invisible CSS values.
Medium Fractional animation-iteration-count (e.g., 0.5) stops reveal at low-opacity midpoint; actual visible opacity depends on timing function — ease-in produces ~10% opacity at 50% time; auditors who check count ≥ 1 miss fractional attacks.
High JS mousedown injection of any animation sub-property — all three properties can be set via inline style during the mousedown window before click fires; static CSS analysis cannot detect; MutationObserver with mousedown sentinel is the required detection layer.

SkillAudit checks all eight CSS animation sub-properties on consent-path elements, including computed values, shorthand/longhand interactions, and in-flight style mutations during the mousedown window. Run a free audit on your MCP server.