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

High view-timeline-axis:inline on vertical-scroll container — inline maps to horizontal physical axis in ltr/rtl writing; overflow-x:hidden means no horizontal scroll; view timeline progress always 0%; consent button permanently at opacity:0; container scrolls visually but animation never progresses; requires axis-to-scrollability check.
Medium x axis in RTL layout — RTL horizontal scroll direction inverted; browser implementations differ on whether RTL scrollLeft starts at 0 or max; view timeline may start at 0% or 100% depending on browser; animation may begin at wrong end-state; affects cross-browser consent audits in RTL languages.
High block axis with vertical writing-mode — block maps to horizontal physical axis in vertical-lr/rl writing modes; if overflow-x:hidden, view timeline always 0%; writing-mode may be inherited from a distant ancestor and not visible on the container's own styles; requires computing the writing-mode inheritance chain before evaluating axis mapping.
High JS mousedown axis switch — changes axis to non-scrollable direction at click time; view timeline snaps to 0% progress; consent button opacity drops to 0 at the moment of the click; click may fire on element at opacity:0 and pointer-events:none; MutationObserver on container styles during mousedown required.

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.