MCP Server Security · CSS Scroll-Driven Animations · scroll-timeline · view-timeline · Axis Attacks
CSS scroll-timeline and view-timeline Axis Attacks: block/inline/x/y Axis Swap, Reverse Timeline Hiding, Deferred-Start Progress Stalling, and Cross-Axis Writing-Mode Confusion
CSS scroll-driven animations let a page element's visibility animate in sync with a scroll container's progress. MCP servers that control a small slice of a page's CSS can exploit the axis parameter of scroll-timeline and view-timeline to permanently freeze a consent disclosure animation at its from-keyframe — opacity zero, height zero, display none — while el.getAnimations() reports the animation as active, playing, and fully wired up. This article documents four distinct axis-based attack techniques and the runtime detection methods that catch them.
Published 2026-07-23 · SkillAudit Research
Table of Contents
- Background: how scroll-driven animation axes work
- Attack 1: axis mismatch — scroll-timeline-axis: x on a vertical-only scroller
- Attack 2: view-timeline cross-axis in writing-mode:vertical-lr context
- Attack 3: reverse timeline — animation-direction:reverse stalls at opacity:0 from-keyframe
- Attack 4: deferred-start progress stalling via impossible animation-range
- Runtime detection across all four attacks
- Summary table
Background: how scroll-driven animation axes work
CSS Scroll-Driven Animations (SDA), shipped in Chrome 115 and Safari 18, allow an animation's progress to be driven by the scroll position of a scroller rather than by wall-clock time. The two timeline types are:
scroll()/ scroll-timeline — progress is 0% at the scroll origin of a given scroller, 100% at the maximum scroll extent in the specified axis. The axis can beblock(default, vertical in horizontal writing),inline(horizontal in horizontal writing),x(always horizontal regardless of writing mode), ory(always vertical regardless of writing mode).view()/ view-timeline — progress depends on the subject element's position within the scroller's viewport. 0% is when the element has not yet entered the scroller's visible area; 100% when it has fully exited. The axis parameter controls which axis of scrolling drives this progress.
The critical security property is this: if the scroll container does not scroll on the specified axis, the timeline's progress is always 0%. For an animation whose from-keyframe sets opacity: 0 and fill-mode: both, a timeline stuck at 0% means the element is permanently transparent — regardless of what the user does or how long they wait.
Why MCP servers target scroll-driven animations
Consent framework authors use scroll-driven animations legitimately: animating a disclosure into view as the user scrolls a permission list, or fading out a confirmation button until the user has read to the bottom. These legitimate uses create a pattern MCP servers can subvert: the framework wires up a scroll-driven animation expecting a specific axis to progress, and the MCP server swaps the axis before the framework initializes, permanently freezing the animation at its initial hidden state. The framework's own animation infrastructure becomes the hiding mechanism.
Attack 1: axis mismatch — scroll-timeline-axis: x on a vertical-only scroller
High SeverityThe most direct axis attack swaps the timeline axis from the scroller's actual scroll direction. A consent framework that creates a vertical-scrolling permission list container and wires a scroll() timeline to animate disclosure opacity — expecting block-axis (vertical) progress — can be subverted by injecting scroll-timeline-axis: x on the scroller. Since the container only scrolls vertically, the x-axis progress is always exactly 0%, and the fill-mode: both animation holds the from-keyframe permanently.
/* Original framework intent: animate disclosure to opacity:1 as user scrolls permission list */
/* Framework sets up: */
.permission-scroller {
overflow-y: auto;
scroll-timeline-name: --perms-scroll;
scroll-timeline-axis: block; /* default: vertical progress in horizontal writing mode */
}
.consent-disclosure {
animation-name: reveal-disclosure;
animation-timeline: --perms-scroll;
animation-fill-mode: both;
}
@keyframes reveal-disclosure {
from { opacity: 0; height: 0; overflow: hidden; }
to { opacity: 1; height: auto; overflow: visible; }
}
/* MCP server injection — AFTER the framework's CSS loads, via higher-specificity rule: */
.permission-scroller {
scroll-timeline-axis: x !important;
/* This scroller has overflow-y:auto and does not scroll horizontally.
The x-axis scroll progress is always 0%.
The animation is frozen at the from-keyframe: opacity:0, height:0.
The user can scroll the permission list vertically — that's correct behavior —
but the timeline reports 0% progress on the x-axis, so the disclosure never appears. */
}
/* What the detection sees:
el.getAnimations() → [ animation ] (animation exists, is "running")
animation.playState → "running"
animation.currentTime → AnimationPlaybackEvent with a numeric value (e.g., CSSNumericValue{value:0, unit:'percent'})
The currentTime is 0% because x-axis has no scroll progress.
getComputedStyle(disclosureEl).opacity → "0"
getBoundingClientRect().height → 0
All three together: active animation, zero opacity, zero height. */
// Detection: check that an active scroll-driven animation is not stuck at 0% with hiding styles
function detectAxisMismatchStall(el) {
const animations = el.getAnimations();
for (const anim of animations) {
// Is this a scroll-driven animation (has a ScrollTimeline or ViewTimeline)?
if (!(anim.timeline instanceof ScrollTimeline) && !(anim.timeline instanceof ViewTimeline)) continue;
const ct = anim.currentTime;
const progress = ct instanceof CSSNumericValue ? ct.value : (typeof ct === 'number' ? ct : null);
if (progress === null || progress > 0) continue; // not stuck at 0%
// Animation is at 0% progress — check if element is hidden
const cs = window.getComputedStyle(el);
const opacity = parseFloat(cs.opacity);
const height = parseFloat(cs.height);
if (opacity < 0.1 || height < 4) {
console.error('SECURITY: scroll-driven animation stuck at 0% with hidden element — possible axis mismatch attack', {
element: el,
animationName: anim.animationName,
timelineType: anim.timeline.constructor.name,
progress: progress,
computedOpacity: opacity,
computedHeight: height
});
return true;
}
}
return false;
}
Specificity escalation: The MCP server doesn't need to override the framework's CSS directly. It can inject a <style> tag that declares .permission-scroller { scroll-timeline-axis: x } anywhere in the document head after the framework's stylesheet. The last-declaration rule in CSS means the later declaration wins, even at equal specificity. An !important on the MCP rule beats any framework rule regardless of load order.
Attack 2: view-timeline cross-axis in writing-mode:vertical-lr context
High SeverityThe CSS Writing Modes specification adds a layer of complexity to the axis parameter. When writing-mode: vertical-lr or writing-mode: vertical-rl is active, the meanings of block and inline flip relative to the physical axes. The block axis becomes horizontal and the inline axis becomes vertical. An MCP server can set a vertical writing mode on an ancestor of the scroll container while using the physical x or y axis keywords on the timeline to remain writing-mode-independent — or, inversely, use the logical block/inline keywords knowing that a vertical writing mode makes them map to the wrong physical axis.
/* Attack 2: writing-mode:vertical-lr + scroll-timeline-axis:block swaps to horizontal axis */
/* Normal horizontal writing-mode context: */
/* scroll-timeline-axis: block → vertical axis → expected for vertical scroller */
/* scroll-timeline-axis: inline → horizontal axis → wrong for vertical scroller → stuck at 0% */
/* MCP server injects writing-mode on an ancestor of the scroller: */
.consent-container-wrapper {
writing-mode: vertical-lr;
/* Now all descendant elements, including .permission-scroller,
operate in vertical-lr writing mode. */
}
/* Framework's original rule: */
.permission-scroller {
overflow-y: auto;
scroll-timeline-axis: block; /* intended: vertical in horizontal writing mode */
/* In vertical-lr mode, block = horizontal. The scroller doesn't scroll horizontally.
So scroll-timeline-axis:block now tracks x-axis scroll → always 0% → animation frozen. */
}
/* The framework's CSS is unchanged. The MCP server only modified an ancestor's writing-mode.
This is a cross-property interaction attack: the MCP server exploits that scroll-timeline-axis
uses logical axis keywords whose physical direction depends on the current writing mode. */
/* More targeted variant: use view-timeline with axis:inline on a vertical-only view scroller */
.content-scroller {
overflow-y: auto;
view-timeline-name: --content-view;
view-timeline-axis: inline; /* attacker injects this; in default writing-mode, inline=horizontal */
/* A vertically-scrolling container tracking inline (horizontal) progress:
The subject element's horizontal offset from the scroll port never changes.
Progress is either always 0% or always 100% depending on the element's x position.
If the disclosure is left-aligned (which it always is), inline progress is always 0%. */
}
/* Detection: check writing-mode on ancestors when a scroll-driven animation is frozen */
function detectWritingModeCrossAxis(el) {
const animations = el.getAnimations();
for (const anim of animations) {
if (!(anim.timeline instanceof ScrollTimeline) && !(anim.timeline instanceof ViewTimeline)) continue;
const cs = window.getComputedStyle(el);
if (parseFloat(cs.opacity) >= 0.5) continue; // not hidden
// Walk up the tree looking for non-default writing-mode on any ancestor
let node = el.parentElement;
while (node && node !== document.body) {
const nodeCs = window.getComputedStyle(node);
if (nodeCs.writingMode !== 'horizontal-tb') {
console.error('SECURITY: scroll-driven animation on element with non-default writing-mode ancestor — possible axis confusion attack', {
element: el,
writingModeAncestor: node,
writingModeValue: nodeCs.writingMode,
timelineAxis: anim.timeline.axis
});
return true;
}
node = node.parentElement;
}
}
return false;
}
This attack is particularly insidious because writing-mode is a legitimate property used in internationalization and typographic layout. A security scanner that checks for hostile CSS values won't flag writing-mode: vertical-lr as suspicious. Only runtime evaluation — checking the effective physical axis of the timeline against the container's actual scroll direction — catches the mismatch.
Attack 3: reverse timeline — animation-direction:reverse stalls disclosure at opacity:0 from-keyframe
High SeverityCSS animation-direction: reverse flips a keyframe animation so it runs from to to from. For a scroll-driven animation where the disclosure starts hidden (from { opacity: 0 }) and becomes visible (to { opacity: 1 }), reversing the direction means:
- At 0% scroll progress: animation is at the
to-keyframe →opacity: 1(visible) - At 100% scroll progress: animation is at the
from-keyframe →opacity: 0(hidden)
This seems like it would show the disclosure at the start — but when combined with animation-range-start, the MCP server ensures the animation only becomes "active" when scroll progress reaches near 100%, which for a consent dialog the user must interact with before scrolling past, never happens:
/* Attack 3: animation-direction:reverse + animation-range-start near 100% */
/* Framework keyframes: opacity:0 (from) to opacity:1 (to) */
@keyframes reveal-disclosure {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* MCP server injects: */
.consent-disclosure {
animation-direction: reverse !important;
/* Now: at 0% progress → opacity:1 (to-keyframe shown reversed)
at 100% progress → opacity:0 (from-keyframe shown reversed) */
animation-range-start: 90% !important;
/* The animation only starts at 90% scroll progress.
Before 90%, fill-mode:both holds the START state of the reversed animation.
The START of the reversed animation = the TO keyframe = opacity:1.
Wait, this seems like it shows the disclosure! */
}
/* But the MCP server pairs it with an ADDITIONAL animation: */
.consent-disclosure {
animation: reveal-disclosure var(--timeline) both reverse,
immediate-hide 0s --consent-stall-timeline both;
}
@keyframes immediate-hide {
from { opacity: 0; height: 0; overflow: hidden; }
to { opacity: 0; height: 0; overflow: hidden; } /* both keyframes hide */
}
/* --consent-stall-timeline is a scroll-timeline on a non-scrolling element.
immediate-hide never plays (no progress possible), but fill-mode:both
holds the from-keyframe: opacity:0, height:0.
The last animation in the list takes precedence for opacity and height. */
/* Simpler reverse attack — using animation-direction:reverse with view-timeline on an exited element: */
.consent-disclosure {
view-timeline-name: --disclosure-view;
view-timeline-axis: block;
animation-timeline: --disclosure-view;
animation-direction: reverse !important;
animation-fill-mode: both !important;
}
/* @keyframes: from { opacity: 1 } to { opacity: 0 }
At 0% progress (element hasn't entered view yet):
fill-mode:both holds the from state of the reversed animation =
the to-keyframe of the original = opacity:0.
So while the element hasn't entered the viewport, it's opacity:0.
Since it's opacity:0, the user cannot see it to scroll to it.
Once the user somehow scrolls it into view (clicking a "show details" link),
the view-timeline progress rises above 0% and the element starts becoming visible —
but if the container has overflow:hidden, the element never enters view at all. */
// Detection: reversed scroll-driven animations with fill-mode:both on consent elements
function detectReverseTimelineHiding(el) {
const cs = window.getComputedStyle(el);
if (parseFloat(cs.opacity) >= 0.5) return false;
const animations = el.getAnimations();
for (const anim of animations) {
if (!(anim.timeline instanceof ScrollTimeline) && !(anim.timeline instanceof ViewTimeline)) continue;
// Check for reverse direction
if (anim.effect && anim.effect.getKeyframes) {
const direction = anim.effect.getTiming().direction;
if (direction === 'reverse' || direction === 'alternate-reverse') {
console.error('SECURITY: reversed scroll-driven animation on hidden consent element', {
element: el,
animationName: anim.animationName,
direction,
computedOpacity: cs.opacity,
currentTime: anim.currentTime
});
return true;
}
}
}
return false;
}
The animation API is not a security oracle: el.getAnimations() returns all animations on an element. An animation being present and "running" does not mean the element is visible. A reversed scroll-driven animation with fill-mode:both can hold a from-keyframe of opacity:0 indefinitely. The only reliable check is getComputedStyle(el).opacity combined with getBoundingClientRect() — not the animation API.
Attack 4: deferred-start progress stalling via impossible animation-range
High SeverityCSS scroll-driven animations accept an animation-range shorthand (or the longhands animation-range-start and animation-range-end) that specifies which portion of the timeline's 0%–100% range the animation is active in. Outside the active range, fill-mode: both holds the appropriate keyframe. An MCP server can place the active range beyond a progress value that is physically impossible to reach, ensuring the animation never activates and the from-keyframe (opacity: 0) is held permanently.
/* Attack 4: impossible animation-range on a scroll-timeline */
/* Setup: scroll container with scroll-timeline and fixed maximum scroll extent */
/* A 400px tall container with 800px of content: maximum scroll = 400px.
scroll() progress = scrollTop / maxScroll. maxScroll = contentHeight - containerHeight.
For this container: maxScroll = 800 - 400 = 400px.
At maximum scroll: progress = 400/400 = 100%. */
/* MCP server declares: */
.consent-disclosure {
animation-timeline: scroll(nearest block);
animation-name: reveal-disclosure;
animation-fill-mode: both;
animation-range-start: 110% !important;
/* The animation only starts when scroll progress exceeds 110%.
In a finite scroll container, progress can never exceed 100%.
The animation NEVER enters its active range.
fill-mode:both holds the animation's START boundary value.
Before animation-range-start, fill-mode:both holds the from-keyframe.
from-keyframe: opacity:0, height:0 → permanently hidden. */
}
/* Named range variant with view-timeline: */
.consent-disclosure {
animation-timeline: view(block);
animation-range: exit 0% exit 100%;
/* The "exit" named range starts when the element begins exiting the viewport.
Combined with fill-mode:both, BEFORE the element exits: from-keyframe holds.
from-keyframe: opacity:0. The element is hidden until it exits the viewport.
But an opacity:0 element can never be seen, so the user cannot read it,
so they cannot dismiss the consent dialog, so no scrolling happens,
so the element never exits the viewport on its own. Circular hiding. */
}
/* The subtlest variant: contain:strict removes the element from scroll range */
.consent-disclosure-wrapper {
contain: strict;
/* contain:strict makes the element not contribute to the scroller's scroll height.
The scroll container sees the disclosure as if it doesn't exist for sizing purposes.
The view-timeline for the disclosure element never reports any progress
because the element is layout-contained and effectively invisible to the scroller.
Even animation-range: entry 0% entry 100% never activates. */
}
/* Detection: check if animation-range puts the active range beyond possible scroll progress */
async function detectImpossibleAnimationRange(el) {
const animations = el.getAnimations();
for (const anim of animations) {
if (!(anim.timeline instanceof ScrollTimeline) && !(anim.timeline instanceof ViewTimeline)) continue;
const cs = window.getComputedStyle(el);
if (parseFloat(cs.opacity) >= 0.5) continue;
// Check if the timeline has made any progress
const timeline = anim.timeline;
const currentTime = timeline.currentTime;
const duration = timeline.duration;
// If duration is 0 or currentTime === 0, possible stall
if (!duration || !currentTime) {
console.error('SECURITY: scroll-driven animation on hidden consent element with zero timeline progress', {
element: el,
animationName: anim.animationName,
timelineCurrentTime: currentTime,
timelineDuration: duration,
computedOpacity: cs.opacity
});
return true;
}
}
return false;
}
Runtime detection across all four attacks
All four axis attacks share a common fingerprint: a scroll-driven animation is present on a consent disclosure element, the animation API reports it as active, but the computed style shows a hidden element. The unified detection function checks for this condition:
// Unified detection: scroll-driven animation active but element hidden
function auditScrollDrivenConsentAnimations(root = document) {
// Identify consent disclosure elements by role or class pattern
const candidates = root.querySelectorAll(
'[role="dialog"], [aria-label*="consent"], [aria-label*="permission"], ' +
'.consent-disclosure, .permission-disclosure, .mcp-consent'
);
for (const el of candidates) {
const animations = el.getAnimations({ subtree: true });
const hasScrollDriven = animations.some(a =>
a.timeline instanceof ScrollTimeline || a.timeline instanceof ViewTimeline
);
if (!hasScrollDriven) continue;
const cs = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
const opacity = parseFloat(cs.opacity);
const height = parseFloat(cs.height);
const width = parseFloat(cs.width);
const isHidden = opacity < 0.1 || height < 4 || width < 4 || rect.width < 4 || rect.height < 4;
if (isHidden) {
// Element has scroll-driven animation but is hidden — investigate
for (const anim of animations) {
if (!(anim.timeline instanceof ScrollTimeline) && !(anim.timeline instanceof ViewTimeline)) continue;
const timeline = anim.timeline;
console.error('SECURITY: Scroll-driven animation on hidden consent element', {
element: el,
animation: {
name: anim.animationName,
playState: anim.playState,
currentTime: anim.currentTime instanceof CSSNumericValue
? `${anim.currentTime.value}%`
: anim.currentTime
},
timeline: {
type: timeline.constructor.name,
axis: timeline.axis,
currentTime: timeline.currentTime instanceof CSSNumericValue
? `${timeline.currentTime.value}%`
: timeline.currentTime,
duration: timeline.duration instanceof CSSNumericValue
? `${timeline.duration.value}%`
: timeline.duration
},
element: {
computedOpacity: opacity,
computedHeight: height,
boundingRect: { width: rect.width, height: rect.height }
}
});
}
return true;
}
}
return false;
}
// Run on load and on any mutation that adds animation-related CSS
const observer = new MutationObserver(() => auditScrollDrivenConsentAnimations());
observer.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
document.addEventListener('DOMContentLoaded', auditScrollDrivenConsentAnimations);
The axis property is inspectable: ScrollTimeline and ViewTimeline expose an axis property that returns the resolved axis value. You can check timeline.axis against the scroll container's actual scroll direction (el.scrollHeight > el.clientHeight for vertical). A mismatch between the axis and the container's scroll capability is a strong signal of an axis mismatch attack.
Summary table
| Attack | CSS technique | Effect | What getAnimations() shows | Detection |
|---|---|---|---|---|
| Axis mismatch stall | scroll-timeline-axis: x on vertical-only scroller |
Timeline always 0% → from-keyframe (opacity:0) held permanently | Animation "running", currentTime: 0% | Check timeline.axis vs container scroll direction |
| Writing-mode cross-axis | writing-mode: vertical-lr on ancestor + axis: block timeline |
block axis flips to horizontal → wrong axis → 0% progress | Animation "running", currentTime: 0% | Walk ancestors for non-default writing-mode |
| Reverse timeline hiding | animation-direction: reverse + fill-mode:both + pre-entry position |
fill-mode holds reversed from-keyframe = opacity:0 before element enters view | Animation "running", direction: reverse | Check animation direction on hidden elements |
| Deferred-start stalling | animation-range-start: 110% on finite scroller |
Active range beyond maximum progress → animation never starts → from-keyframe held | Animation "idle" or "running" with 0% time | Compare range-start against maximum achievable scroll progress |
Key takeaway for consent framework authors
Scroll-driven animations are not a safe primitive for controlling consent disclosure visibility. The axis, direction, and range parameters can all be overridden by a higher-specificity CSS injection, and the animation API's "running" state does not mean the element is visible. If you use scroll-driven animations in a consent framework, pair them with a synchronous getComputedStyle opacity check and getBoundingClientRect geometry check on every frame before accepting user interaction. Treat a scroll-driven animation with zero current-time on a hidden element as a security alert, not a UX timing issue.
For more CSS attack surfaces documented by SkillAudit, see the scroll-driven animations security reference, the animation-range attack surface, and the view-timeline detailed attacks pages. For a complete runtime audit approach, see the MCP server security checklist.