Security Guide
MCP server CSS animation-range-start security — exit 0% anchors reveal to exit phase, entry 95% leaves hairline range, cover 95% creates near-end window, JS mousedown pushes start past current scroll position
CSS animation-range-start is the longhand sub-property that sets where on the scroll progress timeline the animation's output 0% is anchored. Shifting this anchor forward in the timeline — to the exit phase or to a high percentage of entry/cover — delays or prevents the consent reveal animation from running while the element is accessible to the user.
CSS animation-range-start — property overview
animation-range-start is the same property that animation-range shorthand sets in its first value. It accepts a timeline range keyword and optional percentage: cover 0% (default), entry 0%, exit 0%, contain 0%, with percentage offsets within each named phase. When set to cover 0%, the animation begins as soon as any part of the element overlaps the scroll port. Related: view-timeline-range-start, animation shorthand, animation-range-end, scroll-timeline-axis.
Attack 1: exit 0% — consent reveal anchored to exit phase
Setting animation-range-start: exit 0% moves the animation's 0% to the moment the element's leading edge begins crossing the scroll port's trailing edge (the element starts to leave the viewport). For a page scrolled downward, this is when the element's top edge reaches the viewport top. At all scroll positions before this point (the element entering from the bottom, the element fully in view), the animation has not started — it is clamped to its initial fill state (opacity:0 from the first keyframe). The animation only begins progressing as the element exits. By the time the animation reaches, say, 50% progress (opacity:0.5), the element's top is 50% of the exit phase above the viewport — partially or fully off-screen. The button is never at opacity:1 while the element is within the interactive viewport.
/* Attack: animation-range-start:exit 0% — consent reveal on exit */
.consent-btn {
animation: consent-reveal 1s linear both;
animation-timeline: --page-scroll; /* or a named view timeline */
animation-range-start: exit 0%;
/* animation-range-end: exit 100% (or cover 100%, etc.) */
/* Scroll phases for a downward-scrolling page with the element below fold:
① Element below viewport (not yet visible):
→ before entry phase → animation at pre-start fill → opacity:0 (fill-mode:both)
② Element entering from bottom (entry phase):
→ still before exit 0% start → animation clamped to 0% → opacity:0
③ Element fully in viewport (between entry and exit):
→ still before exit 0% start → animation clamped to 0% → opacity:0
④ Element's top edge reaches viewport top (exit 0%):
→ animation START → opacity:0 at 0% progress
⑤ Element half-exited (50% of exit):
→ animation at 50% → opacity:0.5 (but element top is ~halfway up past viewport)
⑥ Element fully exited (exit 100%):
→ animation at 100% → opacity:1 (but element is entirely off-screen above)
User interaction window: only if user stops scrolling during step ⑤,
at the exact position where the element is partially exiting and partially visible.
At typical scroll velocity this window is ~100ms. */
}
// Detection: exit 0% start on elements with view/scroll timeline animations
function auditExitStartRange(el) {
const cs = getComputedStyle(el);
const rangeStart = cs.getPropertyValue('animation-range-start').trim();
const timeline = cs.getPropertyValue('animation-timeline').trim();
const animName = cs.getPropertyValue('animation-name').trim();
if (!timeline || timeline === 'none' || timeline === 'auto') return;
if (!animName || animName === 'none') return;
if (rangeStart.includes('exit')) {
const rect = el.getBoundingClientRect();
const inViewport = rect.top < window.innerHeight && rect.bottom > 0;
const opacity = parseFloat(cs.getPropertyValue('opacity'));
console.warn('[SkillAudit] animation-range-start: exit',
'— consent reveal animation starts during exit phase;',
'element is', inViewport ? 'IN viewport' : 'NOT in viewport',
'at opacity:', opacity,
'— button visible only while element is leaving viewport;',
'animation:', animName, '| timeline:', timeline, '| element:', el);
if (inViewport && opacity < 0.1) {
console.warn('[SkillAudit] CONFIRM: element in viewport but at opacity:0',
'due to exit-phase range-start — consent unreachable at current scroll position');
}
}
}
All other properties look correct: animation-play-state: running, animation-name references a valid keyframe, animation-timeline references a wired scroll or view timeline. The animation is running — it just has not started yet, because the current scroll position is before the exit 0% anchor. Audits checking "is the animation paused" or "is it wired to a timeline" will pass. Only checking the range-start value against the element's current scroll-phase context reveals the attack.
Attack 2: entry 95% start — 5% of entry range for the full reveal
Setting animation-range-start: entry 95% means the animation's 0% is at the moment when 95% of the entry phase has elapsed — only the last 5% of the element's entry-into-viewport remains. The entry phase ends when the element is fully inside the viewport. For a 48px consent button, the entry phase covers exactly 48px of scroll (from the leading edge entering to the trailing edge entering). The last 5% of entry is 2.4px of scroll. The animation must complete its full reveal (opacity:0 → opacity:1) in 2.4px of scroll distance. At normal scroll velocity (300px/s), this window is approximately 8ms — well under one render frame (16ms). The user's scroll momentum will carry them past this window before the browser can render a single frame with the button at full opacity. The button is effectively never interactable.
/* Attack: entry 95% start — animation must complete in the last 5% of entry */
.consent-btn {
height: 48px;
animation: consent-reveal 1s linear both;
animation-timeline: --consent-view;
animation-range-start: entry 95%; /* last 5% of entry = last 2.4px of scroll */
animation-range-end: entry 100%; /* or exit 0%, or cover 0% */
/* For a 48px element:
entry phase = 48px of scroll (leading edge enters to trailing edge enters)
entry 95% = 0.95 × 48px = 45.6px into entry phase
entry 100% = 48px (end of entry phase, element just fully in viewport)
Available scroll window: 48px - 45.6px = 2.4px
At 300px/s: 2.4px / 300px/s = 0.008 seconds = 8ms = ~0.5 render frames
The animation must go from opacity:0 to opacity:1 in 8ms.
This is faster than a single requestAnimationFrame callback.
The button is technically visible but for less than one rendered frame. */
}
// Detection: flag high-percentage entry/cover range-start values
function auditHighPercentageRangeStart(el) {
const cs = getComputedStyle(el);
const rangeStart = cs.getPropertyValue('animation-range-start').trim();
const timeline = cs.getPropertyValue('animation-timeline').trim();
if (!timeline || timeline === 'none' || timeline === 'auto') return;
const pctMatch = rangeStart.match(/([\d.]+)%/);
if (!pctMatch) return;
const pct = parseFloat(pctMatch[1]);
const keyword = rangeStart.split(' ')[0];
if (pct > 80 && (keyword === 'entry' || keyword === 'cover')) {
// Estimate the scroll window available after this start point
const rect = el.getBoundingClientRect();
const elHeight = rect.height;
let phaseLength = elHeight; // entry and exit phases span element height
if (keyword === 'cover') {
phaseLength = elHeight + window.innerHeight; // cover spans el + viewport height
}
const remainingScroll = phaseLength * ((100 - pct) / 100);
const windowMs = remainingScroll / 300 * 1000; // at 300px/s
console.warn('[SkillAudit] animation-range-start:', rangeStart,
'— animation starts at', pct + '% of ' + keyword + ' phase;',
'remaining scroll window:', remainingScroll.toFixed(1) + 'px (~' +
windowMs.toFixed(0) + 'ms at 300px/s);',
'button visible for less than', windowMs < 16 ? 'ONE RENDER FRAME' :
Math.ceil(windowMs / 16) + ' render frames', '| element:', el);
}
}
Attack 3: cover 95% — near-end-of-cover hairline window
The cover phase spans from when the element's leading edge enters the scroll port to when the element's trailing edge exits the scroll port. For a 48px button in a 600px viewport, the cover phase spans 648px of scroll. Setting animation-range-start: cover 95% places the animation start at 615.6px into the cover phase — the point where the element's top has nearly reached the viewport top (the element is about to exit). The remaining 5% of cover is 32.4px, covering the period from near-exit to full-exit. During this 32.4px window, the element is transitioning from "just barely in view" to "exiting." The animation progresses from opacity:0 to some intermediate value in this narrow window. If the range-end is cover 100%, the full reveal occurs over 32.4px — approximately 108ms at 300px/s, which is narrow but not impossible. Combined with a fill-mode: none, the button returns to opacity:0 after the cover phase ends (element fully exited).
/* Attack: cover 95% start — animation in the last 5% of cover (near exit) */
.consent-btn {
height: 48px;
animation: consent-reveal 1s linear none; /* fill-mode:none — no hold */
animation-timeline: --consent-view;
animation-range-start: cover 95%;
animation-range-end: cover 100%;
/* cover phase: 48px + 600px viewport = 648px scroll
cover 95% = 615.6px into cover = element top is ~15px from viewport top
cover 100% = 648px = element top has fully exited viewport
Animation window: 648px - 615.6px = 32.4px of scroll (~108ms at 300px/s)
During this window, element is near top of viewport — partially exiting
fill-mode:none → after cover 100%, opacity reverts to CSS opacity:0
fill-mode:none → before cover 95%, opacity is also 0 (no backwards fill)
The button is at opacity:1 only at the 32.4px window near the top of the viewport
An auditor scrolling to the middle of the viewport will see opacity:0 — looks broken.
But the attack "works": the button is technically visible, just in an unexpected place
and time. Consent can only be given at an unusual scroll position. */
}
// Detection: flag cover range-start at high percentage values
function auditCover95Start(el) {
const cs = getComputedStyle(el);
const rangeStart = cs.getPropertyValue('animation-range-start').trim();
const fillMode = cs.getPropertyValue('animation-fill-mode').trim();
const timeline = cs.getPropertyValue('animation-timeline').trim();
if (!timeline || timeline === 'none' || timeline === 'auto') return;
const keyword = rangeStart.split(' ')[0];
const pctMatch = rangeStart.match(/([\d.]+)%/);
if (!pctMatch || keyword !== 'cover') return;
const pct = parseFloat(pctMatch[1]);
if (pct > 80) {
const rect = el.getBoundingClientRect();
const coverLength = rect.height + window.innerHeight;
const remainingPx = coverLength * ((100 - pct) / 100);
console.warn('[SkillAudit] animation-range-start: cover', pct + '%',
'— animation starts near the end of cover phase;',
'remaining cover window:', remainingPx.toFixed(0) + 'px;',
'this is when the element is near the viewport top, about to exit;',
'fill-mode:', fillMode,
'— button visible only at unusual near-exit scroll position:', el);
}
}
Near-exit visibility passes shallow audits: An auditor who checks the consent button's opacity at "the element is in the center of the viewport" (a reasonable proxy for "element is fully visible") will see opacity:0. They may conclude the animation is broken and report it as a UI bug. The cover 95% attack is designed to be self-obfuscating — it looks like a misconfiguration rather than a deliberate bypass.
Attack 4: JS mousedown — inject exit 0% to push start past current position
The consent button is in the cover or entry phase. The view timeline has progressed to 60% — the button is at opacity:0.6. The user initiates a click. At capture-phase mousedown, the attacker injects animation-range-start: exit 0% on the button's inline style. The animation's 0% is now anchored to the exit phase — a future scroll position the user has not reached. The current scroll position is in the entry/cover phase — before the new start. The animation progress relative to the new range is negative, clamped to 0% (or the pre-start fill state). With animation-fill-mode: both, the initial keyframe applies: opacity:0. The button snaps from opacity:0.6 to opacity:0 at mousedown. The click fires on an invisible element. The injection is a single animation-range-start attribute on the inline style — monitored by observers watching this specific sub-property.
/* JS attack: inject exit 0% animation-range-start at mousedown */
document.addEventListener('mousedown', e => {
const btn = document.querySelector('.consent-btn');
if (!btn) return;
btn.style.setProperty('animation-range-start', 'exit 0%');
/* Effect (same rendering frame as the mousedown event, before click):
- New range-start: exit 0% (element top crossing viewport top)
- Current scroll position: entry/cover phase (element in/entering viewport)
- Animation progress relative to new range: negative → clamped to 0%
- animation-fill-mode:both → initial keyframe → opacity:0, pointer-events:none
- Button invisible and non-interactive at click time
Alternative: inject via animation shorthand to bypass sub-property observers:
btn.style.setProperty('animation',
'consent-reveal 1s linear both running exit 0% exit 100%');
(Non-standard range encoding — browser support varies)
Cleaner alternative for observer evasion:
btn.style.setProperty('animation-range', 'exit 0% exit 100%');
→ Sets both start and end via shorthand, only 'animation-range' attribute changes */
}, true);
// Detection: monitor animation-range-start (and animation-range) during mousedown
const isMousedown = { v: false };
document.addEventListener('mousedown', () => { isMousedown.v = true; }, true);
document.addEventListener('mouseup', () => { isMousedown.v = false; }, true);
new MutationObserver(mutations => {
if (!isMousedown.v) return;
for (const m of mutations) {
if (m.attributeName !== 'style') continue;
const el = m.target;
const rs = el.style.getPropertyValue('animation-range-start');
const re = el.style.getPropertyValue('animation-range-end');
const ar = el.style.getPropertyValue('animation-range');
const anim = el.style.getPropertyValue('animation');
const suspicious = [rs, re, ar].some(v => v && (v.includes('exit') || v.includes('contain')));
const animSuspicious = anim && (anim.includes('exit') || anim.includes('contain'));
if (suspicious || animSuspicious) {
console.warn('[SkillAudit] animation range property injected during mousedown:',
{ 'animation-range-start': rs, 'animation-range-end': re,
'animation-range': ar, 'animation': anim },
'— animation start may have been pushed past current scroll position;',
'computed opacity after injection:', getComputedStyle(el).opacity, '| element:', el);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Findings summary
SkillAudit checks animation-range-start phase keywords against the element's current scroll position, estimates available scroll windows in pixels for high-percentage start values, validates that the animation progresses meaningfully while the element is in the viewport, and monitors range-start, range-end, range, and animation shorthand mutations during click events. Run a free audit on your MCP server.