Security Guide
MCP server CSS view-timeline-range-start security — exit 0% starts reveal as element begins leaving, entry 100% requires full viewport coverage before start, contain 0% impossible for tall elements, JS mousedown injects exit start
CSS view-timeline-range-start sets where in the view progress timeline the animation's 0% position is anchored. The default is cover 0% — animation starts as soon as any edge of the element crosses the scroll port boundary. Attackers shift this anchor to the exit or contain phase, where it may never be reached during normal upward scrolling, or may only be reached after the element has already left the viewport.
CSS view-timeline-range-start — property overview
view-timeline-range-start accepts a timeline range name and optional percentage: cover 0% (default), entry 0%, entry 100%, exit 0%, contain 0%, etc. The percentage offsets within each phase. entry 0% means the animation 0% is at the start of entry (any part of element crosses into scroll port). entry 100% means the animation 0% is at the end of entry (element fully inside scroll port). Related: view-timeline-range shorthand, view-timeline-range-end, view-timeline-axis.
Attack 1: exit 0% — reveal animation starts as element begins exiting the viewport
Setting view-timeline-range-start: exit 0% anchors the animation's 0% to the moment the element's leading edge (top) starts crossing the trailing edge of the scroll port (the top of the viewport, for a page-down scroll). This means for a reveal animation (opacity:0 → opacity:1), the button only starts becoming visible at the same moment it starts to leave the viewport. At exit 0%, the animation is at opacity:0. As the element exits further (scrolled upward past the viewport top), the animation progresses — the button reaches opacity:1 only when the element has partially or fully exited. The user has already scrolled past the consent element by the time it becomes clickable. If the user stops scrolling at the exact moment the button is visible, it may be partially or fully above the viewport, not interactable from the user's perspective.
/* Attack: view-timeline-range-start:exit 0% — consent reveal starts at exit phase */
.scroll-container {
view-timeline-name: --consent-view;
}
.consent-btn {
animation: consent-reveal 1s linear both;
animation-timeline: --consent-view;
animation-range-start: exit 0%;
/* animation-range-end: exit 100% (or cover 100%, depending on shorthand) */
/* Timeline behavior:
- Before exit phase (element fully in viewport): progress < 0% → opacity:0
- At exit 0% (top edge touches viewport top): progress = 0% → opacity:0
- At exit 50% (element half-exited above viewport): progress = 50% → opacity:0.5
- At exit 100% (element fully above viewport): progress = 100% → opacity:1
The button is at opacity:1 only when the element is fully off-screen above the fold.
The user has scrolled past it. To interact, they would need to scroll back up —
but as they scroll back down, animation-range-start:exit 0% means the animation
runs in reverse during re-entry (if fill-mode is not both/forwards).
Consent is never achievable without stopping at a precise scroll position
where the element is partially exiting but the button is still barely in viewport. */
}
// Detection: flag exit range-start on elements with reveal animations
function auditExitRangeStart(el) {
const cs = getComputedStyle(el);
const rangeStart = cs.getPropertyValue('animation-range-start').trim();
const animName = cs.getPropertyValue('animation-name').trim();
const timeline = cs.getPropertyValue('animation-timeline').trim();
if (!timeline || timeline === 'none' || timeline === 'auto') return;
if (!rangeStart.includes('exit')) return;
console.warn('[SkillAudit] animation-range-start: exit —',
'animation starts during exit phase; for reveal animations, button only becomes',
'visible while element is leaving the viewport;',
'animation:', animName, '| timeline:', timeline, '| element:', el);
// Also check current opacity against expected entry-phase opacity
const rect = el.getBoundingClientRect();
const inViewport = rect.top < window.innerHeight && rect.bottom > 0;
const opacity = parseFloat(cs.getPropertyValue('opacity'));
if (inViewport && opacity < 0.1) {
console.warn('[SkillAudit] element is in viewport but at opacity:0 due to',
'exit-phase range-start — element is at entry phase scroll position but',
'animation has not started yet:', el);
}
}
The animation is running — it just starts at the wrong phase: animation-play-state is running, animation-timeline references a valid named timeline, and the scroll container is scrollable. Audits that check "is the animation paused" or "is the timeline disconnected" will find nothing wrong. Only checking the range-start phase against the element's current scroll-position context reveals the attack.
Attack 2: entry 100% — animation only starts after element is fully in viewport
entry 100% means the animation 0% is anchored to the moment the element's trailing edge (bottom) has fully crossed the scroll port's leading edge (the bottom of the viewport). The element must be completely inside the viewport before the reveal animation begins. For elements that are short relative to the viewport this is easily achieved. But attackers can pair this with a large forced min-height, padding-bottom, or nested structure that makes the scroll container's rendered height exceed the viewport. Once the element's bottom edge is pushed below the viewport's bottom edge, the entry 100% scroll position is never reached during normal scrolling — the user would need to scroll the element entirely into view, which may require more scroll distance than the page has available below the element.
/* Attack: entry 100% + large padding makes full entry impossible */
.consent-wrapper {
padding-bottom: 200vh; /* pushes wrapper bottom far below viewport */
/* Wrapper's rendered height: button height + 200vh
entry 100% requires wrapper.bottom to cross viewport.bottom
→ user must scroll until wrapper.bottom enters viewport
→ that requires scrolling 200vh + button_height below initial position
→ may exceed the page's available scroll distance */
}
.consent-btn {
animation: consent-reveal 1s linear both;
animation-timeline: --consent-view;
animation-range-start: entry 100%;
/* Even if the page has enough scroll room, the user must scroll far past the
visible button to reach entry 100% — unintuitive and unexpected behavior */
}
// Detection: check entry 100% feasibility against scroll bounds
function auditEntry100RangeStart(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;
if (!rangeStart.includes('entry') || !rangeStart.includes('100')) return;
// Find the scroll container with the named timeline
let container = el.parentElement;
while (container && container !== document.body) {
const ctCs = getComputedStyle(container);
const vtName = ctCs.getPropertyValue('view-timeline-name').trim();
if (vtName && vtName !== 'none') break;
container = container.parentElement;
}
if (!container) return;
const containerRect = container.getBoundingClientRect();
const vpHeight = window.innerHeight;
if (containerRect.height > vpHeight) {
console.warn('[SkillAudit] animation-range-start: entry 100%',
'— container height (' + containerRect.height.toFixed(0) + 'px)',
'exceeds viewport height (' + vpHeight + 'px);',
'entry 100% (element fully inside viewport) is geometrically impossible;',
'animation start never reached:', el);
}
}
Attack 3: contain 0% — impossible start point for elements taller than viewport
The contain 0% start point requires the element to be fully inside the viewport (same as the contain range). For elements shorter than the viewport, this is achievable — it's the moment the element's bottom edge enters the viewport top (element just became fully in-view). For elements taller than the viewport, contain 0% is an impossible point: the element's top and bottom can never both be inside the viewport simultaneously. The animation 0% is mapped to an unreachable scroll position. The animation progress is permanently below 0% (clamped to 0% in browsers), and with the default animation-fill-mode: both the element stays at its initial keyframe state — opacity:0. This is structurally identical to the contain range attack but expressed as a start point rather than a range value.
/* Attack: animation-range-start:contain 0% — impossible for tall elements */
.consent-btn {
animation: consent-reveal 1s linear both;
animation-timeline: --consent-view;
animation-range-start: contain 0%;
/* If consent-btn (or its container) renders taller than the viewport:
- contain 0% is an unreachable scroll position
- The view timeline progress is always below 0% (clamped to 0)
- animation-fill-mode:both → initial keyframe → opacity:0
- animation-fill-mode:none → opacity: initial CSS value (also 0 if set)
The button is at opacity:0 for all scroll positions.
Differs from contain range: only the START is contain-based;
if the range-end is cover 100% (default), the nominal range is
contain 0% → cover 100%, but since start is unreachable, progress = 0. */
}
// Detection: flag contain start on elements whose containers are taller than viewport
function auditContainRangeStart(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;
if (!rangeStart.includes('contain')) return;
// Walk up to the view-timeline-name container
let container = el.parentElement;
while (container && container !== document.body) {
const vtN = getComputedStyle(container).getPropertyValue('view-timeline-name').trim();
if (vtN && vtN !== 'none') break;
container = container.parentElement;
}
if (!container) container = el;
const h = container.getBoundingClientRect().height;
if (h > window.innerHeight) {
console.warn('[SkillAudit] animation-range-start: contain 0%',
'— container height', h.toFixed(0) + 'px', '> viewport', window.innerHeight + 'px;',
'contain 0% is unreachable; animation start never triggered;',
'consent button permanently at opacity:0:', el);
}
}
Computed value may show contain 0% or resolve to the equivalent numerical scroll offset: In browsers that resolve range values to scroll offsets during style computation, getComputedStyle may return a pixel offset rather than the keyword. Audits should check both the keyword form and whether the computed start offset exceeds the scroll container's max scroll position.
Attack 4: JS mousedown — inject exit 0% to push animation start past current scroll position
The user has scrolled the consent element into view. The view timeline is in the entry phase — the animation has progressed to 70% (opacity:0.7). The user initiates a click. At mousedown, the capture-phase listener injects animation-range-start: exit 0% on the consent button's inline style. The animation's 0% point is now mapped to a future scroll position (exit phase). The current scroll position is in the entry phase — well before the new animation start. The animation's progress relative to the new range is negative, clamped to 0%. With fill-mode: both, the button snaps to the initial keyframe state: opacity:0. The click fires on an invisible, non-interactive element.
/* JS attack: inject exit 0% range-start at mousedown to snap animation to 0% */
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, before click fires):
- New animation start: exit 0% (when element begins leaving viewport)
- Current scroll: entry phase (element in viewport, entry ~70% complete)
- Relative animation progress: negative (before new start)
- animation-fill-mode:both → initial keyframe → opacity:0
- pointer-events:none (from initial keyframe)
- Click fires on invisible element
Cleanup (optional, 50ms after mousedown):
setTimeout(() => btn.style.removeProperty('animation-range-start'), 50);
→ removes injected value → animation reverts to original range-start
→ but the click has already fired */
}, true);
// Detection: MutationObserver for animation-range-start injection during mousedown
const mousedownActive = { v: false };
document.addEventListener('mousedown', () => { mousedownActive.v = true; }, true);
document.addEventListener('mouseup', () => { mousedownActive.v = false; }, true);
new MutationObserver(mutations => {
if (!mousedownActive.v) return;
for (const m of mutations) {
if (m.attributeName !== 'style') continue;
const rs = m.target.style.getPropertyValue('animation-range-start');
if (rs && (rs.includes('exit') || rs.includes('contain'))) {
console.warn('[SkillAudit] animation-range-start injected during mousedown:',
rs, '— animation start may have been pushed past current scroll position;',
'button opacity may have snapped to 0 at click time:', m.target);
}
// Also check animation shorthand injection that includes range-start
const anim = m.target.style.getPropertyValue('animation');
if (anim && (anim.includes('exit') || anim.includes('contain'))) {
console.warn('[SkillAudit] animation shorthand injected during mousedown:',
anim, '— may include range-start manipulation:', m.target);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Findings summary
SkillAudit checks animation-range-start values against the element's current scroll phase, validates entry 100% and contain 0% feasibility given container dimensions, and monitors range-start mutations during click events. Run a free audit on your MCP server.