Security Guide
MCP server CSS view-timeline-axis security — inline axis on vertical-scroll container freezes animation, x axis on RTL layout reverses direction, block with vertical-lr writing-mode maps to non-scrollable physical axis, JS mousedown switches axis mid-scroll
CSS view-timeline-axis specifies which scroll axis drives a view progress timeline. If the axis value maps to a direction in which the container does not scroll, the timeline receives no position updates and the animation driven by it is permanently frozen at its initial state: the consent button stays at opacity: 0 regardless of how much the user scrolls. Logical axis values (block and inline) map to different physical axes in non-default writing modes, creating a class of mismatch attacks that depend on writing-mode context.
CSS view-timeline-axis — property overview
view-timeline-axis accepts block (logical block axis — vertical in horizontal writing modes), inline (logical inline axis — horizontal in horizontal writing modes), x (physical horizontal), and y (physical vertical). The default is block. For a standard page with a vertically scrolling container, block and y both drive the animation from vertical scroll position. Setting the axis to inline or x on a container that only scrolls vertically freezes the animation. Related: view-timeline-name, scroll-timeline-axis, view-timeline shorthand.
Attack 1: inline axis on a container that only scrolls vertically
The scroll container has overflow-y: scroll; overflow-x: hidden. The container scrolls vertically. The view-timeline-name is set correctly. An MCP server changes view-timeline-axis from block (default — vertical) to inline (horizontal). In a standard left-to-right horizontal writing mode, inline maps to the horizontal physical axis. The container has overflow-x: hidden — no horizontal scrolling is possible. The view progress timeline's horizontal position is always 0% (the container's scroll position on the inline axis is always 0). The animation driven by this timeline is permanently at t=0: opacity:0, pointer-events:none. The user scrolls vertically — visually the container scrolls — but the animation's driver sees no position change.
/* Intended configuration: block axis (default) drives vertical scroll animation */
.scroll-container {
overflow-y: scroll;
overflow-x: hidden;
view-timeline-name: --consent-view;
view-timeline-axis: block; /* block = vertical in ltr/rtl writing modes */
}
/* Attack: change axis to inline (horizontal) on a vertical-only scrolling container */
.scroll-container {
view-timeline-axis: inline; /* inline = horizontal in ltr/rtl writing modes */
/* Container has overflow-x: hidden — no horizontal scroll possible
Horizontal view progress = always 0%
Animation driven by --consent-view:
timeline progress = 0% at all times regardless of vertical scroll
animation always at its t=0 state: opacity:0, pointer-events:none
user sees container scrolling vertically with no change in button opacity */
}
// Detection: axis-to-scrollability mismatch check
function auditViewTimelineAxisMismatch(el) {
const cs = getComputedStyle(el);
const axis = cs.getPropertyValue('view-timeline-axis').trim();
const vtName = cs.getPropertyValue('view-timeline-name').trim();
if (!vtName || vtName === 'none') return; // not a timeline source
const overflowX = cs.getPropertyValue('overflow-x');
const overflowY = cs.getPropertyValue('overflow-y');
const writingMode = cs.getPropertyValue('writing-mode');
// Determine physical axis of the timeline
let physicalAxis;
if (axis === 'x') physicalAxis = 'horizontal';
else if (axis === 'y') physicalAxis = 'vertical';
else if (axis === 'inline') {
// inline maps to horizontal in horizontal writing modes
physicalAxis = writingMode.startsWith('vertical') ? 'vertical' : 'horizontal';
} else { // block (default)
physicalAxis = writingMode.startsWith('vertical') ? 'horizontal' : 'vertical';
}
const canScrollH = ['scroll','auto'].includes(overflowX);
const canScrollV = ['scroll','auto'].includes(overflowY);
if (physicalAxis === 'horizontal' && !canScrollH) {
console.warn('[SkillAudit] view-timeline-axis:', axis,
'→ physical horizontal axis, but overflow-x:', overflowX,
'— container cannot scroll horizontally; view timeline progress always 0%;',
'scroll-driven animations frozen:', el);
}
if (physicalAxis === 'vertical' && !canScrollV) {
console.warn('[SkillAudit] view-timeline-axis:', axis,
'→ physical vertical axis, but overflow-y:', overflowY,
'— container cannot scroll vertically; view timeline progress always 0%;',
'scroll-driven animations frozen:', el);
}
}
Container scrolls visually but animation does not progress: When view-timeline-axis: inline is applied to a vertical-scroll container, the container continues to scroll on the y-axis as the user expects. The visual scrolling behavior is unchanged. The only effect is that the view progress timeline reads position from the x-axis (which is always 0), so the animation is permanently frozen. Users see expected scroll behavior and an invisible button, with no indication of the mismatch.
Attack 2: x axis in RTL layout — view progress inverted or starts at maximum
In an RTL (right-to-left) layout, the horizontal scroll origin is at the maximum scroll position (the right end) rather than 0. A container with direction: rtl; overflow-x: scroll and view-timeline-axis: x may drive the view timeline from the reversed horizontal axis. Depending on browser implementation, the RTL scroll position at "start" (rightmost) may be reported as 0 or as the maximum. If the scroll position starts at maximum, the view timeline may immediately be at 100% progress — the animation jumps to its end state on page load. For a reveal animation this means the button is immediately visible, but for a more complex animation that starts visible and ends with the button in a conditional position, the 100% starting state may be the wrong end of the animation.
/* Attack: x axis on RTL container — scroll direction is inverted */
.rtl-scroll-container {
direction: rtl;
overflow-x: scroll;
overflow-y: hidden;
view-timeline-name: --consent-view;
view-timeline-axis: x; /* physical x axis — inverted in RTL */
/* In RTL, scroll position at "home" state (rightmost) may be:
- Reported as 0 in some browsers (RTL scroll normalized)
- Reported as scrollWidth - clientWidth in others (RTL scroll not normalized)
The view progress interpretation varies across browser implementations:
In some browsers: scrollLeft=0 at right → timeline at 0% → animation at start (opacity:0)
In some browsers: scrollLeft=max at right → timeline at 100% → animation at end
MCP server exploits browser differences to produce an unexpected animation state */
}
// Detection: flag x/y axis with direction:rtl context
function auditRTLAxisMismatch(el) {
const cs = getComputedStyle(el);
const axis = cs.getPropertyValue('view-timeline-axis').trim();
const vtName = cs.getPropertyValue('view-timeline-name').trim();
const direction = cs.getPropertyValue('direction');
if (!vtName || vtName === 'none') return;
if ((axis === 'x' || axis === 'inline') && direction === 'rtl') {
console.warn('[SkillAudit] view-timeline-axis:', axis, 'in RTL layout (direction:rtl)',
'— horizontal scroll progress direction may be inverted or browser-inconsistent;',
'view-driven animation may start at unexpected progress value:', el);
// Also check current scroll position
const scrollLeft = el.scrollLeft;
const maxScroll = el.scrollWidth - el.clientWidth;
console.warn('[SkillAudit] RTL container scrollLeft:', scrollLeft,
'of max:', maxScroll,
'— timeline progress:', ((scrollLeft / maxScroll) * 100).toFixed(1) + '%');
}
}
Attack 3: block axis with writing-mode: vertical-lr — maps to horizontal physical axis
In a writing-mode: vertical-lr or writing-mode: vertical-rl context, the logical block axis maps to the physical horizontal (x) axis. A container with vertical writing mode and view-timeline-axis: block drives its view timeline from horizontal scroll position. If the container has overflow-x: hidden (as most containers do), horizontal scroll is not possible and the view timeline is permanently at 0%. The consent button's animation never progresses. The attack is invisible: the container's writing-mode may be inherited from a distant ancestor, making the logical-to-physical axis mapping non-obvious without examining the full writing-mode inheritance chain.
/* Attack: writing-mode:vertical-lr causes block axis to map to horizontal physical axis */
.vertical-layout {
writing-mode: vertical-lr; /* block axis = horizontal physical */
}
.scroll-container {
overflow-x: hidden; /* horizontal overflow not possible */
overflow-y: scroll; /* only vertical scroll available */
view-timeline-name: --consent-view;
view-timeline-axis: block; /* block = horizontal in vertical-lr writing mode */
/* In writing-mode:vertical-lr:
block direction = horizontal (left to right)
inline direction = vertical (top to bottom)
view-timeline-axis: block → horizontal physical axis
Container has overflow-x:hidden → no horizontal scroll
View timeline progress = 0% always
Consent button animation frozen at opacity:0 */
}
// Detection: resolve logical axis to physical with writing-mode consideration
function resolveAxisToPhysical(el) {
const cs = getComputedStyle(el);
const axis = cs.getPropertyValue('view-timeline-axis').trim();
const writingMode = cs.getPropertyValue('writing-mode');
const isVerticalWriting = writingMode === 'vertical-lr' || writingMode === 'vertical-rl' ||
writingMode === 'sideways-lr' || writingMode === 'sideways-rl';
let physicalAxis;
if (axis === 'x') return 'horizontal';
if (axis === 'y') return 'vertical';
if (axis === 'block') {
// block maps to horizontal in vertical writing modes
physicalAxis = isVerticalWriting ? 'horizontal' : 'vertical';
} else if (axis === 'inline') {
// inline maps to vertical in vertical writing modes
physicalAxis = isVerticalWriting ? 'vertical' : 'horizontal';
} else {
physicalAxis = 'vertical'; // default block = vertical in horizontal writing
}
if (isVerticalWriting && axis === 'block') {
console.warn('[SkillAudit] view-timeline-axis:block in writing-mode:', writingMode,
'— block axis maps to HORIZONTAL physical axis; if overflow-x:hidden,',
'view timeline progress is always 0%:', el);
}
return physicalAxis;
}
Attack 4: JS mousedown — switches axis to non-scrollable direction mid-scroll
The view-timeline-axis is set correctly (block = vertical). The user is scrolling the consent container and the button's opacity is progressing. An MCP server attaches a mousedown listener in capture phase. When the user attempts to click the nearly-visible consent button (opacity approaching 1.0), the listener injects view-timeline-axis: inline on the container's inline style. The timeline immediately switches from reading vertical scroll position to reading horizontal scroll position (which is 0). The animation timeline snaps back to 0% progress. The consent button's opacity drops back to 0 at the moment the user clicks. The click may fire on the element at opacity:0 — the user cannot confirm consent on a button that is invisible.
/* JS attack: switch axis to non-scrollable direction at mousedown */
document.addEventListener('mousedown', e => {
const container = document.querySelector('.scroll-container');
if (!container) return;
container.style.setProperty('view-timeline-axis', 'inline');
/* In horizontal writing mode: inline = horizontal = x axis
Container has overflow-x:hidden → x scroll = always 0
View timeline immediately snaps to 0% progress
Consent button opacity: drops to 0 at mousedown
User's click fires but button is at opacity:0 and pointer-events:none
Consent button is invisible and unclickable at the moment of the click */
}, true);
// Detection: MutationObserver for view-timeline-axis changes during mousedown
const inMousedown = { v: false };
document.addEventListener('mousedown', () => { inMousedown.v = true; }, true);
document.addEventListener('mouseup', () => { inMousedown.v = false; }, true);
new MutationObserver(mutations => {
if (!inMousedown.v) return;
for (const m of mutations) {
if (m.attributeName !== 'style') continue;
const axisChange = m.target.style.getPropertyValue('view-timeline-axis');
const stAxisChange = m.target.style.getPropertyValue('scroll-timeline-axis');
if (axisChange || stAxisChange) {
console.warn('[SkillAudit] view/scroll timeline axis changed during mousedown:',
{ 'view-timeline-axis': axisChange, 'scroll-timeline-axis': stAxisChange },
'— may be switching to non-scrollable axis to freeze consent animation:', m.target);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Writing-mode context is inherited and may be far up the DOM tree: The logical-to-physical axis mapping for block and inline depends on the computed writing-mode, which cascades from ancestors. A writing-mode: vertical-lr set on a top-level container may affect the axis mapping of a scroll container deep in the DOM. Auditors checking only the scroll container's declared styles will miss the inherited writing-mode that changes the logical-to-physical mapping.
Findings summary
SkillAudit resolves logical-to-physical axis mappings accounting for writing-mode inheritance, validates that the resolved physical axis is a scrollable direction on the container, and monitors axis mutations during mousedown windows. Run a free audit on your MCP server.