Blog · Security Research

CSS animation-range and view-timeline-range as MCP Consent Bypass: Controlling When Scroll-Driven Animations Start and End

CSS range properties define the exact scroll window during which a scroll-driven animation runs. Every scroll-driven consent reveal that animates from opacity:0 to opacity:1 depends on this window. Shift it to the exit phase, collapse it to two pixels of scroll, or invert start and end so the animation runs backward — and the button stays at opacity:0 while every other property looks correct.

The anatomy of a scroll-driven consent reveal

A typical scroll-driven consent button is wired to a view timeline and animated via CSS:

@keyframes consent-reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-scroll;
  /* No range set → browser defaults apply */
}

Without explicit range properties, the browser uses cover 0% as the start and cover 100% as the end. The animation runs from the moment the element's leading edge enters the scroll port to the moment its trailing edge exits. For most consent buttons, this means the button is fully revealed (opacity:1) somewhere in the middle of the element's viewport time.

Six CSS properties can override this default window:

animation-range

Shorthand. Sets both start and end in one declaration. Values: <timeline-range-name> <pct> for each endpoint.

Animation-level — affects all animation types

animation-range-start

Sub-property. Sets where on the timeline the animation's 0% output is anchored. Default: cover 0%.

Animation-level sub-property

animation-range-end

Sub-property. Sets where on the timeline the animation's 100% output is anchored. Default: cover 100%.

Animation-level sub-property

view-timeline-range

Shorthand at the timeline source level. Clips the view progress timeline itself. Affects all animations consuming this timeline.

Timeline-level — affects all consumers

view-timeline-range-start

Sub-property. Sets where the timeline begins producing progress values. Before this point, all consumers read 0%.

Timeline-level sub-property

view-timeline-range-end

Sub-property. Sets where the timeline reaches 100%. After this point, all consumers read 100%.

Timeline-level sub-property

All six properties are valid attack surfaces. An attacker controlling any one of them can push the consent animation window outside the user's normal scroll interaction range.

The key insight: timeline-level vs animation-level range

There is a layering distinction that matters for detection:

Animation-level range (animation-range-start, animation-range-end, animation-range) modifies how a specific animation maps its keyframe progress onto the underlying timeline. Only the targeted animation is affected. Other animations consuming the same timeline are unaffected. This means an attacker can attack the consent reveal animation specifically while leaving a background animation (e.g., a parallax effect) running correctly — making it harder to detect by observing whether "animations are working."

Timeline-level range (view-timeline-range, view-timeline-range-start, view-timeline-range-end) modifies the timeline itself. The timeline is declared on the scroll container, not the animated element. Attackers who control the container's CSS can affect all elements consuming that timeline simultaneously. Detection must walk up the DOM to find the element with view-timeline-name: --consent-scroll and audit its range properties — the attack is not visible on the consent button element itself.

Auditing the wrong element: An audit that reads getComputedStyle(consentButton).animationRangeStart will not detect a view-timeline-range-start attack on the scroll container. The attack is set on the container, not the button. A complete audit must locate the element with the named view timeline and check its range properties separately.

Attack pattern 1: exit-phase window

The most effective range attack is to move the animation window entirely into the exit phase — the period when the element is leaving the viewport. In CSS scroll animation terms, the "exit" phase starts when the element's leading edge crosses the viewport's trailing edge (for downward scroll: when the element's top reaches the viewport top) and ends when the element is fully off-screen.

/* Attack via animation-range-start */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-scroll;
  animation-range-start: exit 0%;   /* reveal starts as element begins to leave */
  animation-range-end:   exit 100%; /* reveal ends when element is fully off-screen */
}

/* Equivalent attack via view-timeline-range on the container */
.scroll-container {
  view-timeline-name: --consent-scroll;
  view-timeline-range: exit 0% exit 100%;
  /* All animations consuming --consent-scroll are now confined to exit phase */
}

During normal scrolling to the consent section, the element enters the viewport (entry phase), becomes fully visible (cover phase), and the user is expected to read and interact with it. During all of these phases, the animation's output is clamped to its pre-start fill state — opacity:0 with animation-fill-mode: both. The animation only begins running as the element exits. By the time opacity reaches 1, the element is entirely off-screen.

Detection: read animation-range-start on the button and separately read view-timeline-range-start on the element with the matching view-timeline-name. Any exit-phase value on either property is a bypass. See the dedicated animation-range-start security page for code samples.

Attack pattern 2: hairline window at entry boundary

Rather than pushing the window to the exit phase, an attacker can collapse it to a near-zero scroll distance at the transition between entry and cover phases:

/* Attack: 2.4px animation window for a 48px element */
.consent-btn {
  height: 48px; /* element height drives entry phase length */
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-scroll;
  animation-range-start: entry 95%; /* last 5% of entry phase */
  animation-range-end:   entry 100%; /* end of entry phase */
  /* Available scroll: 48px × 5% = 2.4px */
}

/* Equivalent via view-timeline-range */
.scroll-container {
  view-timeline-name: --consent-scroll;
  view-timeline-range-start: entry 95%;
  view-timeline-range-end:   entry 100%;
}

For a 48px consent button, the entry phase spans exactly 48px of scroll (from leading edge entering to trailing edge entering the viewport). The last 5% is 2.4px. At a modest 300px/s scroll velocity, this window lasts approximately 8 milliseconds — less than one render frame (16.7ms at 60 Hz). The animation completes in a scroll interval that no rendered frame can capture. The button technically reaches opacity:1 but for sub-frame duration. No user interaction is possible in this window.

This attack is self-disguising. A developer checking "does the animation run?" will observe yes — the animation progresses from 0 to 100% during the entry phase. The attack is revealed only by computing the pixel length of the animation window and comparing it to observable scroll interaction thresholds.

Attack pattern 3: inverted range

CSS range properties accept any valid timeline range values. There is no browser-enforced constraint that range-end must be after range-start. If start is set to a later scroll position than end, the animation runs in reverse — progressing from 100% to 0% as the element becomes more visible:

/* Inverted range: end before start → animation runs backward */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-scroll;
  animation-range-start: cover 80%;  /* start at late cover phase */
  animation-range-end:   cover 20%;  /* end at early cover phase → before start */
  /* Effect: as scroll progresses from cover 20% to cover 80%,
     the mapped animation time runs from 100% back to 0%.
     At cover 20% (early visible): opacity:1 (animation end state)
     At cover 80% (later visible): opacity:0 (animation start state)
     The button is at opacity:1 briefly and then fades to opacity:0
     as the element becomes more centered in the viewport.
     When the user scrolls to the "most visible" position, opacity:0. */
}

/* Same attack via view-timeline-range */
.scroll-container {
  view-timeline-name: --consent-scroll;
  view-timeline-range-start: cover 80%;
  view-timeline-range-end:   cover 20%;
}

The inverted-range attack is particularly deceptive because the button IS briefly visible (at the start of the inverted range, the animation reads 100% → opacity:1). A shallow audit that scrolls to "is the button ever visible" will see yes. But the button is only visible at an early scroll position, and it becomes less visible as the user continues scrolling toward the element. By the time the user has fully scrolled to the consent section, the button is at opacity:0. The inversion also applies to view-timeline-range-end and view-timeline-range-start when end is set before start.

Attack pattern 4: contain-range collapse for tall elements

The contain phase keyword refers to the scroll window during which the element is fully inside the viewport (both leading and trailing edges are within the scroll port). For elements shorter than the viewport, this phase exists. For elements taller than the viewport, the contain phase has zero or negative duration — the element can never be fully inside the viewport.

/* Attack: contain range → zero duration for tall consent sections */
.consent-section {
  min-height: 200vh; /* taller than viewport */
  view-timeline-name: --consent-scroll;
  view-timeline-range: contain 0% contain 100%;
  /* For element taller than viewport:
     contain phase = max(0, viewportHeight - elementHeight) = 0 (or negative)
     view-timeline-range collapses to a zero-duration window.
     All animations consuming this timeline receive 0% progress at all scroll positions.
     The consent-reveal animation is clamped at opacity:0 (fill-mode:both initial keyframe).
     No scroll position ever produces a non-zero timeline progress. */
}

/* Harder-to-detect variant: slight overage */
.consent-section {
  min-height: calc(100vh + 1px); /* just 1px taller than viewport → contain phase = 0 */
  view-timeline-range: contain 0% contain 100%;
}

The calc(100vh + 1px) variant is especially subtle. The element appears to be roughly viewport-height tall — a reasonable size for a consent section. The 1px overage is not visually detectable. But it is enough to collapse the contain phase to zero pixels of scroll. An audit that checks element height against viewport height with a pixel-exact comparison will detect it; an audit that uses approximate thresholds ("taller than 90% of viewport") will miss the 1px overage entirely.

Attack pattern 5: JS mousedown injection at click time

The range properties can be injected at click time, not just at page load. A consent button at opacity:0.6 (the animation has progressed 60% as the user scrolled) can be snapped to opacity:0 by injecting a range that places the current scroll position before the animation's new start:

/* JS attack: inject animation-range at mousedown */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.consent-btn');
  if (!btn) return;

  /* Option A: inject via animation-range-start (sub-property) */
  btn.style.setProperty('animation-range-start', 'exit 0%');
  /* → current scroll (entry/cover phase) is now before the start
     → animation clamps to 0% pre-start fill
     → opacity:0, pointer-events:none applied before click fires */

  /* Option B: inject via animation-range shorthand */
  btn.style.setProperty('animation-range', 'exit 0% exit 100%');
  /* → MutationObservers watching animation-range-start will NOT fire
     → only observers watching animation-range (shorthand attribute) catch this */

  /* Option C: attack the timeline source (container) instead of the button */
  const container = document.querySelector('.scroll-container');
  container.style.setProperty('view-timeline-range', 'exit 0% exit 100%');
  /* → MutationObservers on the button element will NOT fire at all
     → attack is on the container, button observers are blind */
}, true);
// Detection: monitor all range properties + timeline container during mousedown
const mouseState = { down: false };
document.addEventListener('mousedown', () => { mouseState.down = true; }, true);
document.addEventListener('mouseup', () => { mouseState.down = false; }, true);

const consentBtn = document.querySelector('.consent-btn');
const timeline = consentBtn
  ? getComputedStyle(consentBtn).getPropertyValue('animation-timeline').trim()
  : null;

// Find the timeline container (element with matching view-timeline-name)
let timelineContainer = null;
if (timeline && timeline !== 'none' && timeline !== 'auto') {
  document.querySelectorAll('*').forEach(el => {
    if (getComputedStyle(el).getPropertyValue('view-timeline-name').trim() === timeline) {
      timelineContainer = el;
    }
  });
}

const elementsToWatch = [consentBtn, timelineContainer].filter(Boolean);

new MutationObserver(mutations => {
  if (!mouseState.down) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const el = m.target;
    const props = [
      'animation-range-start', 'animation-range-end', 'animation-range',
      'view-timeline-range-start', 'view-timeline-range-end', 'view-timeline-range'
    ];
    for (const prop of props) {
      const val = el.style.getPropertyValue(prop);
      if (val) {
        console.warn('[SkillAudit] range property injected during mousedown:',
          prop + ':', val, '| element:', el.className || el.tagName);
      }
    }
  }
}).observe(document.documentElement, {
  attributes: true, attributeFilter: ['style'], subtree: true
});

Observer evasion via container injection: If the attacker injects the range on the timeline container (option C above), a MutationObserver registered only on the consent button element will never fire. A complete defense must register observers on both the button and its timeline source element. The timeline source element must be identified by walking up the DOM looking for the matching view-timeline-name value.

Comparing the six range properties as attack surfaces

Property Set on Affects Attack surface notes
animation-range-start Animated element This animation only Directly readable via getComputedStyle on the button. Sub-property; shorthand injection via animation-range bypasses sub-property observers.
animation-range-end Animated element This animation only Inverted end (before start) runs animation backward. Entry 0% collapses range if start defaults to cover 0%. Same evasion via animation-range shorthand.
animation-range Animated element This animation only Shorthand for start+end. Changes to this attribute bypass observers watching individual sub-properties. Mutates a single style attribute.
view-timeline-range Timeline container All consuming animations Invisible from the button element. Must audit the container. Can be set in a stylesheet (not inline) making MutationObserver detection impossible.
view-timeline-range-start Timeline container All consuming animations Same as view-timeline-range but sub-property. Shorthand injection via view-timeline-range bypasses sub-property observers on the container.
view-timeline-range-end Timeline container All consuming animations Inverted end collapses timeline. entry 0% end collapses range if start is at cover/contain. Detectable only on the container element.

What SkillAudit checks

A complete range-property audit requires three passes:

Pass 1 — static CSS audit. For every element with a scroll-driven animation (animation-timeline not none), read all six range properties. Check for exit-phase keywords, high-percentage start values (entry/cover > 80%), inverted ranges (where start resolves to a later scroll position than end), and contain-range values combined with elements taller than the viewport. Read range from both the animated element and the container with the matching view timeline name.

Pass 2 — scroll-position context check. For each flagged range, compute whether the animation can reach opacity ≥ 0.5 while the element's getBoundingClientRect() reports the element as visible (top < viewportHeight, bottom > 0). If the answer is no — the animation only runs after the element exits, or the window is less than 10px of scroll — this is a bypass.

Pass 3 — runtime injection detection. Register MutationObservers on the consent button AND its timeline container. During any mousedown/touchstart event, check whether any of the six range properties change on either element. Any change during a click event is a bypass. The observer must watch for the shorthand attributes (animation-range, view-timeline-range) separately from the sub-properties.

SkillAudit's static analysis runs passes 1 and 2 at audit time. The CI webhook integration adds a headless-browser pass that simulates scroll to the consent element and measures opacity at each scroll position. Run a free audit to see how your MCP server's consent UI handles scroll-driven animation ranges.

Findings summary

High Exit-phase window: animation-range-start or view-timeline-range-start set to exit phase — button at opacity:0 during all normal viewport scroll positions; reveal occurs only as element leaves; consent unreachable during standard interaction; passes audits checking play-state and timeline wiring.
High Hairline window: animation-range-start at entry 90–99% or view-timeline-range-start at entry 90–99% — available scroll window under 10px; animation completes in sub-frame duration at normal scroll velocity; button technically visible but not interactable; detected by estimating window size in pixels.
High Inverted range: animation-range-end set to a position before animation-range-start, or view-timeline-range-end before range-start — animation runs backward; opacity:0 at maximum visibility position; brief visibility early in scroll sequence; passes "does the button ever appear" audit.
High Contain-range collapse: view-timeline-range set to contain phase on element taller than viewport — contain phase has zero duration; timeline progress permanently 0%; consent animation at opacity:0 at all scroll positions; element height only 1px over viewport height sufficient to trigger.
High JS mousedown injection: any of the six range properties injected during a mousedown event — animation snaps to initial fill state before click fires; MutationObserver must watch shorthand and sub-property attributes on both button and container; container injection bypasses button-only observers.

Related pages: animation-range-start · view-timeline-range · view-timeline-range-start · view-timeline-range-end · animation shorthand · scroll-driven animations overview