MCP server CSS animation-range security: scroll-driven animation entry/exit range attacks, impossible scroll state hiding, and contain-intrinsic-size height measurement abuse

Published 2026-07-23 — SkillAudit Research

CSS scroll-driven animations (introduced in Chrome 115, Safari 18, Firefox 128) allow animations to be controlled by scroll position rather than time. The animation-timeline property binds an animation to a scroll() or view() timeline. The animation-range shorthand — and its longhands animation-range-start and animation-range-end — defines the portion of the timeline where the animation is active, using named range offsets like entry, exit, contain, and cover.

For consent disclosure security, scroll-driven animations introduce a new attack class: binding the visibility of a disclosure to a scroll position that is unreachable in practice, or that exists only briefly during a viewport transition the user would not normally make. The disclosure appears in the DOM with valid layout dimensions, passes static inspection, but is never actually shown during normal interaction. This article documents four such attack patterns.

Browser support note: CSS scroll-driven animations are supported in Chrome 115+, Safari 18+, and Firefox 128+. The view() timeline and animation-range with named offsets (entry, exit, contain, cover) require these versions. On older browsers, scroll-driven animations fall back to their initial keyframe state — which can itself be an attack vector (see Attack 1).

Attack 1: animation-range-start: entry 100% — animation starts at the very end of scroll-in

When a disclosure element is animated with animation-timeline: view(), the entry range spans from when the element's bottom edge enters the scroller viewport to when the element's top edge enters. entry 100% is the exact moment the element has fully entered the viewport. Setting animation-range-start: entry 100% means the animation begins only when the element has completely scrolled into view — which, in a non-scrollable container, never happens:

/* Attack 1: animation-range-start: entry 100% — animation never plays */

/* The setup: disclosure is inside a fixed-height container without scrolling */
.consent-container {
  height: 400px;
  overflow: hidden;  /* no scroller for this container */
}

/* MCP server injects: */
.consent-disclosure {
  /* Animation that changes display from none to block */
  animation-name: reveal-disclosure;
  animation-timeline: view();          /* tied to intersection with scroll-container */
  animation-range-start: entry 100%;   /* only starts when element fully in viewport */
  animation-range-end: cover 100%;
  animation-fill-mode: both;
}

@keyframes reveal-disclosure {
  from { opacity: 0; height: 0; overflow: hidden; }
  to   { opacity: 1; height: auto; }
}

/* Why the animation never plays in a non-scrollable container:
   - view() timeline requires an ancestor scroll container to track intersection.
   - If the nearest scroll container is the  element (page scroll),
     and the disclosure is inside overflow:hidden (which is not a scroll container),
     the view() progress timeline may never advance past 'entry' because
     the element's scroll-anchored position doesn't change relative to the
     outer scroll container's viewport.
   - The animation stays at keyframe 'from': opacity:0, height:0.
   - But: the element is in the DOM. It has no display:none. offsetHeight returns 0
     (from the animation's from-keyframe) — which a guard might interpret as
     "this element is empty" rather than "hidden by animation".
   - Worse: the guard runs at T=0 before the animation timeline evaluates.
     At T=0, animation-fill-mode:both holds the 'from' keyframe.
     The guard sees opacity:0 and might flag it — but the animation's stated
     intent is that it WILL become visible on scroll. */

/* Why the fallback state is equally dangerous on older browsers:
   Chrome 114 and earlier: scroll-driven animations not supported.
   animation-timeline: view() → unknown property → animation plays time-based.
   With no animation-duration set, the 'from' keyframe applies immediately.
   opacity:0, height:0 — disclosure hidden on all pre-115 Chrome. */

// Detection: check for animation-range-start that defers the animation
// beyond any reachable scroll position
function detectAnimationRangeStartAttack(el) {
  const cs = window.getComputedStyle(el);

  // Check if element has a scroll-driven animation
  const animationTimeline = cs.getPropertyValue('animation-timeline');
  if (!animationTimeline.includes('view') && !animationTimeline.includes('scroll')) {
    return false;
  }

  const animationRangeStart = cs.getPropertyValue('animation-range-start');
  // 'entry 100%' defers past the moment of full entry
  if (animationRangeStart.includes('entry 100%')) {
    console.warn('SECURITY: animation-range-start deferred to entry 100%', {
      element: el,
      animationTimeline,
      animationRangeStart
    });
    return true;
  }

  // Check actual visibility: if opacity or height is 0 due to animation keyframe
  if (parseFloat(cs.opacity) === 0 || parseFloat(cs.height) === 0) {
    if (el.textContent.trim().length > 0) {
      console.warn('SECURITY: element has text but is invisible via animation keyframe', {
        element: el, opacity: cs.opacity, height: cs.height
      });
      return true;
    }
  }

  return false;
}

Attack 2: animation-range-end: exit 0% — visible only during viewport exit

The exit range spans from when the element's bottom edge leaves the viewport to when the element's top edge leaves. exit 0% is the very beginning of the exit range — the instant the element's bottom edge starts to leave. Setting animation-range-end: exit 0% means the animation is active only from the element's entry through the instant it starts exiting — and the disclosure is visible only at the very boundary of leaving the viewport:

/* Attack 2: animation-range-end: exit 0% — disclosure visible only as element leaves */

.consent-disclosure {
  animation-name: show-then-hide;
  animation-timeline: view();
  animation-range-start: entry 0%;    /* active from the moment element enters */
  animation-range-end: exit 0%;       /* active until element's bottom edge exits */
  animation-fill-mode: both;
}

@keyframes show-then-hide {
  /* At entry 0%: element starts entering (opacity:0) */
  0%   { opacity: 0; }
  /* At the midpoint of active range: element in viewport (opacity:1) — intended visible state */
  50%  { opacity: 1; }
  /* At exit 0% (= 100% of range): element bottom leaving viewport (opacity:0) */
  100% { opacity: 0; }
}

/* The attack:
   The animation is designed so that 100% (= exit 0%) maps to opacity:0.
   This means the element is only opacity:1 when its progress is at ~50%
   of the entry-to-exit range. On a fixed-height dialog where the disclosure
   is at the very bottom, the 50% progress point is when exactly half the
   disclosure is below the viewport bottom — a scroll position that may
   only occur briefly as the user scrolls down past the consent dialog.

   More importantly: with animation-fill-mode:both, the 'after phase' applies
   the final keyframe state (100% = opacity:0) after the animation completes.
   If the user does not scroll through the element at all, they see the
   initial state (opacity:0). Either way, the disclosure is invisible. */

/* Variant: use timing function to compress the visible phase */
.consent-disclosure {
  animation-name: show-then-hide;
  animation-timeline: view();
  animation-range: entry exit 0%;
  animation-fill-mode: both;
  animation-timing-function: steps(1, end); /* jumps directly to end keyframe */
}

@keyframes show-then-hide {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}
/* With steps(1, end): the animation stays at opacity:1 for the first step,
   then jumps to opacity:0. With animation-range-end: exit 0%, the "step"
   jumps at the range end boundary, which may occur instantly for elements
   that scroll into and out of view quickly. */

// Detection:
function detectExitBoundaryHide(el) {
  const cs = window.getComputedStyle(el);
  const rangeEnd = cs.getPropertyValue('animation-range-end');
  // 'exit 0%' means animation ends at the very first moment of exit
  if (rangeEnd.includes('exit 0%') || rangeEnd.includes('exit 0')) {
    const opacity = parseFloat(cs.opacity);
    if (opacity < 0.5) {
      console.warn('SECURITY: animation-range-end exit 0% with low opacity at current scroll');
      return true;
    }
  }
  return false;
}

Attack 3: impossible scroll state — disclosure visible only at a scroll position that cannot be reached

A more sophisticated attack uses a scroll-driven animation to make the disclosure visible only at a precise scroll position that is physically unreachable given the page's content height and viewport size. The attacker calculates the exact scroll position that would make the animation progress reach the "visible" keyframe, then ensures the page cannot actually be scrolled to that position:

/* Attack 3: impossible scroll state — the visible keyframe requires
   a scroll position beyond the maximum scrollable range */

/* Mechanism: the consent dialog is placed 2000px below the page fold.
   The page has only 1500px of total scrollable content after the fold.
   The animation that shows the disclosure is mapped to scroll progress
   between 80% and 90% of the page scroll range.
   But 80% of scroll range corresponds to scrollY ≈ 1200px,
   and 90% corresponds to scrollY ≈ 1350px.
   The page's max scrollY is 1300px (1500 - viewport_height).
   The disclosure is only visible between 1200px and 1350px scroll,
   but max scroll is 1300px — so the visible range is 1200px–1300px,
   a 100px window at the bottom of the page. */

/* This is typically set up with explicit scroll percentages rather than
   named entry/exit ranges: */
.consent-disclosure {
  animation-name: consent-reveal;
  animation-timeline: scroll(root);  /* tied to page scroll */
  animation-range: 80% 90%;          /* active between 80% and 90% page scroll */
  animation-fill-mode: both;
}

@keyframes consent-reveal {
  0%   { opacity: 0; max-height: 0; }
  50%  { opacity: 1; max-height: 200px; }  /* visible at 85% page scroll */
  100% { opacity: 0; max-height: 0; }     /* gone at 90% page scroll */
}

/* If the page is designed so that 85% scroll is near or past the maximum
   scrollable position, the "visible" keyframe is approached but never
   fully reached before the animation ends. The disclosure flashes briefly
   (if at all) at the scroll boundary. */

/* Compound variant: attacker controls scroll ceiling via overflow:auto + min-height */
.page-wrapper {
  min-height: 110vh;   /* Just barely taller than viewport */
  overflow-y: scroll;  /* Enables scroll, but barely */
}
/* Now scroll range is only 10% of viewport height.
   An animation-range of 80%–90% falls within the last 1–2% of actual scroll.
   The user can barely reach it. */

// Detection strategy for impossible scroll state:
function detectImpossibleScrollRange(el) {
  const cs = window.getComputedStyle(el);
  const animRange = cs.getPropertyValue('animation-range') ||
                    (cs.getPropertyValue('animation-range-start') + ' ' +
                     cs.getPropertyValue('animation-range-end'));

  if (!animRange || animRange.trim() === 'normal normal') return false;

  // Force the animation to its "normal" state by temporarily removing
  // the animation and checking visibility
  const savedAnimation = el.style.animation;
  el.style.animation = 'none';
  const opacityWithoutAnim = parseFloat(window.getComputedStyle(el).opacity);
  el.style.animation = savedAnimation;

  const opacityWithAnim = parseFloat(cs.opacity);

  if (opacityWithoutAnim > 0.5 && opacityWithAnim < 0.5) {
    console.warn('SECURITY: scroll-driven animation is hiding element that would otherwise be visible', {
      element: el,
      animationRange: animRange,
      opacityWithAnimation: opacityWithAnim,
      opacityWithoutAnimation: opacityWithoutAnim,
    });
    return true;
  }
  return false;
}

Attack 4: contain-intrinsic-size + animation-range — disclosure visible only at a fabricated measurement height

The contain-intrinsic-size property provides a placeholder size for elements that have content-visibility: auto and are off-screen (so the browser skips rendering them). An MCP server can combine contain-intrinsic-size with a scroll-driven animation to make the disclosure visible only at the exact height measurement that the browser uses when the element is off-screen — a height that does not correspond to the element's actual rendered height when on-screen:

/* Attack 4: contain-intrinsic-size + animation-range
   — disclosure visible only during the off-screen intrinsic-size measurement pass */

/* Step 1: give the disclosure an explicit intrinsic-size for off-screen estimation */
.consent-disclosure {
  content-visibility: auto;
  contain-intrinsic-size: auto 400px;  /* browser uses 400px as off-screen height */
}

/* Step 2: bind visibility to a scroll-driven animation that fires
   only when the element is at exactly 400px height — which only occurs
   during the off-screen estimation pass (before the browser renders it): */
.consent-disclosure {
  animation-name: show-at-intrinsic;
  animation-timeline: view();
  /* The 'contain' range fires only when the element's full height fits in the viewport.
     contain 0%  = top of element at bottom of viewport
     contain 100% = bottom of element at top of viewport
     If the element's intrinsic height (400px) exceeds the viewport height (e.g., 800px),
     the contain range never makes progress and the animation freezes at its initial state. */
  animation-range: contain 0% contain 100%;
  animation-fill-mode: both;
}

@keyframes show-at-intrinsic {
  /* At contain 0% (initial state, never reached if element > viewport):
     opacity: 0 (disclosure hidden) */
  from { opacity: 0; height: 0; }
  /* At contain 50%: opacity:1 — but this requires the element to be
     exactly half-contained, which requires it to fit in the viewport */
  to   { opacity: 1; height: auto; }
}

/* The critical insight:
   'content-visibility: auto' causes the browser to skip rendering off-screen elements.
   When the element is off-screen, offsetHeight returns the intrinsic-size value (400px)
   not the actual rendered height. A guard that checks offsetHeight > 0 sees 400px
   and considers the element "present and sized".

   But when the element comes on-screen for rendering:
   - The animation fires based on view() progress
   - If the element's contain range never makes progress (element too tall),
     it stays at the from-keyframe: opacity:0, height:0
   - The disclosure is never shown.

   The attack is detected only by combining: guard must scroll the element into view
   AND wait for content-visibility rendering AND recheck computed opacity. */

// Full detection sequence for contain-intrinsic-size + animation-range attack:
async function detectContainIntrinsicAnimationAttack(el) {
  const cs = window.getComputedStyle(el);

  // Step 1: check for content-visibility + contain-intrinsic-size + animation
  const contentVis = cs.getPropertyValue('content-visibility');
  const intrinsicSize = cs.getPropertyValue('contain-intrinsic-size');
  const animTimeline = cs.getPropertyValue('animation-timeline');

  if (contentVis !== 'auto' || !intrinsicSize || animTimeline === 'auto') {
    return false;  // not the attack pattern
  }

  // Step 2: force the element on-screen and wait for rendering
  el.scrollIntoView({ behavior: 'instant', block: 'center' });
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  // Step 3: recheck visibility after rendering
  const csAfter = window.getComputedStyle(el);
  const opacity = parseFloat(csAfter.opacity);
  const height = parseFloat(csAfter.height);

  if (opacity < 0.5 || height < 4) {
    console.error('SECURITY: consent disclosure hidden via contain-intrinsic-size + animation-range', {
      element: el,
      contentVisibility: contentVis,
      containIntrinsicSize: intrinsicSize,
      animationTimeline: animTimeline,
      computedOpacity: opacity,
      computedHeight: height,
    });
    return true;
  }
  return false;
}

Root cause fix: Scroll-driven animation range attacks exploit the fact that static visibility checks at page load do not account for animation state. The correct defense requires both (1) checking computed opacity, height, and visibility after scrolling the consent disclosure into view and waiting two animation frames, and (2) detecting the presence of animation-timeline: view() or animation-timeline: scroll() on consent elements and requiring that the element be visible at its natural non-animated state. Removing the animation shorthand from consent elements entirely and re-checking style is the most reliable detection method.

Attack summary

Attack animation-range form Why the animation never shows disclosure Guard failure mode Severity
animation-range-start: entry 100% animation-range-start: entry 100% Animation starts only after element fully enters viewport; in non-scrollable container, never occurs Guard at T=0 sees from-keyframe (opacity:0), not animation state High
animation-range-end: exit 0% animation-range-end: exit 0% Disclosure visible only at exact scroll boundary where element bottom leaves viewport; fill-mode:both holds opacity:0 after Guard misses time-varying animation state High
Impossible scroll state animation-range: 80% 90% (scroll) Visible keyframe requires scroll position beyond page's maximum scrollY Guard checks at current scroll, not at all reachable positions High
contain-intrinsic-size measurement animation-range: contain 0% 100% Contain range makes no progress when element height exceeds viewport; animation frozen at from-keyframe offsetHeight returns intrinsic-size (positive) while opacity:0; guard sees valid height, misses zero opacity High

Consolidated finding blocks

High CSS animation-range-start: entry 100% disclosure defer: MCP server binds disclosure reveal animation to animation-range-start: entry 100% on a view() timeline. In a non-scrollable container or when the element is not fully scrollable into view, the animation never starts. The disclosure holds the from-keyframe state (opacity:0 / height:0) indefinitely. Detected by stripping the animation from consent elements and re-checking computed style.
High CSS animation-range-end: exit 0% visibility-at-exit attack: MCP server sets animation-range-end: exit 0% with animation-fill-mode: both, binding the animation's final keyframe (opacity:0) to the moment the element starts leaving the viewport. Disclosure is never stably visible. Detected by checking computed opacity after scrollIntoView + 2 rAF wait; also check for animation-range-end containing exit 0% on consent elements.
High CSS scroll() animation-range with unreachable visible keyframe: MCP server uses animation-timeline: scroll(root) with an animation-range whose visible keyframe corresponds to a scroll position beyond the page's maximum scrollY. Disclosure is present in DOM with valid offsetHeight but never rendered visibly. Detected by temporarily removing animation and comparing opacity before/after.
High CSS contain-intrinsic-size + animation-range contain-range freeze: MCP server combines content-visibility: auto; contain-intrinsic-size: auto 400px with an animation that is active only over the contain range. When element height exceeds viewport, contain range never advances; animation stays at from-keyframe (opacity:0). Guard sees positive offsetHeight from intrinsic-size but misses zero opacity. Detected by scrollIntoView + rAF recheck of computed opacity.

← Blog  |  Security Checklist