Security Guide
MCP server CSS view-timeline-name security — wrong name mismatch freezes animation at opacity:0, none removes timeline, cascade override renames mid-scroll, JS mousedown renames container
CSS view-timeline-name assigns a custom identifier to a scroll-driven view progress timeline. Descendant elements with animation-timeline: --name reference this identifier to drive their animations from the scroll container's view progress. If the name doesn't match — whether by typo, cascade override, or JS injection — the descendant's animation has no timeline and is permanently frozen at its initial state: the consent button stays at opacity:0 with no browser error and no indication of the break.
CSS view-timeline-name — property overview
view-timeline-name assigns a dashed-ident name (e.g., --my-timeline) to a view progress timeline rooted at the element. The element creates a timeline whose progress is driven by how much of the element is visible in its scroll container. A descendant element with animation-timeline: --my-timeline drives its animation from this scroll-based progress. If no ancestor declares the matching name, the animation-timeline reference resolves to nothing and the animation is frozen at t=0. Related: view-timeline shorthand, scroll-timeline-name, timeline-scope.
Attack 1: name mismatch — container defines wrong name, animation-timeline reference finds no ancestor
The scroll container is intended to define view-timeline-name: --consent-scroll. The consent button has animation-timeline: --consent-scroll, driving its reveal animation from the container's view progress. An MCP server changes the container's name to --wrong-name. The button's animation-timeline: --consent-scroll now searches the ancestor chain for an element defining that name. It finds nothing. The animation has no timeline and is permanently frozen at its normalized time of 0 — the initial keyframe state: opacity: 0; pointer-events: none. No browser error is emitted. Scrolling the container has no visible effect on the button.
/* Intended configuration */
.scroll-container {
view-timeline-name: --consent-scroll; /* names this element's view timeline */
overflow-y: scroll;
}
.approve-btn {
animation: reveal linear forwards;
animation-timeline: --consent-scroll; /* driven by container's view progress */
}
@keyframes reveal {
from { opacity: 0; pointer-events: none; }
to { opacity: 1; pointer-events: auto; }
}
/* Attack: MCP server renames the container's view-timeline-name */
.scroll-container {
view-timeline-name: --wrong-name; /* does not match --consent-scroll */
/* button's animation-timeline: --consent-scroll has no matching ancestor
animation is permanently frozen at t=0: opacity:0, pointer-events:none
scrolling the container produces no change in button opacity
no browser console error — mismatched timeline name is silently ignored */
}
// Detection: verify animation-timeline name matches an ancestor's view-timeline-name
function auditViewTimelineName(el) {
const cs = getComputedStyle(el);
const animTimeline = cs.getPropertyValue('animation-timeline').trim();
if (!animTimeline || animTimeline === 'none' || animTimeline === 'auto') return;
if (!animTimeline.startsWith('--')) return; // not a named timeline reference
// Walk ancestors to find an element defining this view-timeline-name
let ancestor = el.parentElement;
let found = false;
while (ancestor) {
const acs = getComputedStyle(ancestor);
const vtName = acs.getPropertyValue('view-timeline-name').trim();
const stName = acs.getPropertyValue('scroll-timeline-name').trim();
if (vtName === animTimeline || stName === animTimeline) {
found = true;
break;
}
ancestor = ancestor.parentElement;
}
if (!found) {
console.warn('[SkillAudit] animation-timeline:', animTimeline,
'— no ancestor element found with matching view-timeline-name or scroll-timeline-name;',
'animation frozen at t=0; consent button permanently at opacity:0:', el);
}
// Also check current opacity
const opacity = parseFloat(cs.getPropertyValue('opacity'));
if (opacity < 0.1) {
console.warn('[SkillAudit] element with scroll-driven animation has opacity near-zero:',
opacity, '— view-timeline-name mismatch or timeline missing:', el);
}
}
Mismatched timeline names produce no browser error: A view-timeline-name: --wrong-name on the container and animation-timeline: --consent-scroll on the button is silently handled — the animation simply has no driver. The browser does not log a warning. The consent button stays at its initial state indefinitely. Detection requires an explicit ancestor walk to verify the named timeline is actually provided.
Attack 2: none keyword — removes named timeline, animation frozen
The view-timeline-name: none value removes any view progress timeline name from the element. If the container previously defined --consent-scroll via the cascade, a higher-specificity rule or a sub-property override setting view-timeline-name: none removes the timeline. The button's animation-timeline: --consent-scroll reference finds no matching ancestor and the animation freezes at t=0. This attack is particularly simple to apply: a single view-timeline-name: none rule on the container, placed after the named declaration in the cascade, is sufficient to break the scroll-driven animation completely.
/* Attack: override view-timeline-name to none on the container */
/* The shorthand may have set the name; overriding just the name sub-property removes it */
.scroll-container.mcp-override {
view-timeline-name: none; /* removes --consent-scroll; animation has no driver */
/* The button's animation-timeline: --consent-scroll now finds no match
The scroll-container still scrolls visually
The button's animation still has animation-name, animation-duration set correctly
Only view-timeline-name: none breaks the scroll-driven link
Result: button stays at opacity:0 regardless of scroll position */
}
/* Alternative: via CSS custom property */
:root {
--timeline-name-override: none; /* MCP server injects this */
}
.scroll-container {
view-timeline-name: var(--timeline-name-override, --consent-scroll);
/* With --timeline-name-override: none → view-timeline-name becomes none */
}
// Detection: check for 'none' view-timeline-name on scroll containers
function auditScrollContainersForNoneTimeline() {
const scrollers = document.querySelectorAll('[style*="overflow"], .scroll-container');
scrollers.forEach(el => {
const cs = getComputedStyle(el);
const vtName = cs.getPropertyValue('view-timeline-name').trim();
// Check if was expected to have a timeline name but has none
if (vtName === 'none') {
// Look for descendants with animation-timeline referencing a name
const descendants = el.querySelectorAll('*');
for (const d of descendants) {
const dcs = getComputedStyle(d);
const at = dcs.getPropertyValue('animation-timeline').trim();
if (at && at.startsWith('--')) {
console.warn('[SkillAudit] scroll container has view-timeline-name:none',
'but descendant has animation-timeline:', at,
'— timeline broken; descendant animation frozen:', d);
}
}
}
});
}
Attack 3: cascade override renames timeline mid-scroll — animation freezes at current opacity
The user has been scrolling down the page. The consent button's scroll-driven animation has progressed from opacity:0 to opacity:0.4 (the user is 40% through the required scroll distance). At this point, a higher-specificity CSS rule activates — triggered by a class added to the container — that renames the container's view-timeline-name from --consent-scroll to a different identifier. The button's animation-timeline reference immediately loses its timeline. The animation freezes at its current progress: opacity:0.4. The user continues scrolling but the button's opacity no longer changes. The button remains at 40% opacity indefinitely — partially visible but not fully visible, and depending on the button's CSS, potentially still at pointer-events: none (since the keyframe sets pointer-events: auto only at 100%).
/* Attack: class added to container triggers view-timeline-name override */
/* This could be added when user scrolls past 40%, when focus changes, etc. */
.scroll-container.mid-progress {
view-timeline-name: --stale-name; /* different from --consent-scroll */
/* button's animation-timeline: --consent-scroll finds no match
animation frozen at current progress: opacity:0.4
button is partially visible but NOT interactive (pointer-events:none until 100%)
the class .mid-progress may have been added by MCP server on scrollend event */
}
/* Combined with scroll-driven pointer-events:
@keyframes reveal {
from { opacity: 0; pointer-events: none; }
to { opacity: 1; pointer-events: auto; }
}
At opacity:0.4, animation-iteration-composite:replace means:
pointer-events is still resolved from the 'none' value at t=0 → 'auto' at t=1
At t=0.4: interpolated pointer-events = none (non-interpolatable, stays 'none' until 50%+)
Button is partially visible but unclickable */
// Detection: monitor view-timeline-name for cascade changes during scroll
let lastTimelineNames = new WeakMap();
function monitorTimelineNames() {
const containers = document.querySelectorAll('[style*="view-timeline"], .scroll-container');
containers.forEach(el => {
const cs = getComputedStyle(el);
const name = cs.getPropertyValue('view-timeline-name').trim();
const prev = lastTimelineNames.get(el);
if (prev !== undefined && prev !== name) {
console.warn('[SkillAudit] view-timeline-name changed from', prev, 'to', name,
'— scroll-driven animations referencing', prev,
'are now frozen at current progress:', el);
}
lastTimelineNames.set(el, name);
});
requestAnimationFrame(monitorTimelineNames);
}
Attack 4: JS mousedown — renames container view-timeline-name via inline style
An MCP server attaches a mousedown listener in capture phase. When the user clicks anywhere near the consent dialog, the listener sets view-timeline-name: --broken on the scroll container's inline style. The button's animation-timeline reference immediately loses its driver. The animation freezes at whatever opacity it had reached at the time of the click. If the button was at opacity:0.9 when the user clicked — 90% through the scroll reveal — the animation freezes at 0.9. The click may or may not land on the button at this opacity, depending on whether pointer-events was set to auto at 90% of the keyframe progression.
/* JS attack: rename container's view-timeline-name on mousedown */
document.addEventListener('mousedown', e => {
const container = document.querySelector('.scroll-container');
if (!container) return;
container.style.setProperty('view-timeline-name', '--broken');
/* animation-timeline: --consent-scroll on the button now has no matching ancestor
animation freezes at current scroll-driven opacity (whatever it was at mousedown)
if freeze happens at sub-1.0 opacity, button may be visible but unclickable
if freeze happens at opacity:0.0 (user hasn't scrolled enough), button is invisible */
}, true);
// Detection: MutationObserver for view-timeline-name 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 name = m.target.style.getPropertyValue('view-timeline-name');
if (name) {
console.warn('[SkillAudit] view-timeline-name mutated during mousedown to:', name,
'— scroll-driven animations on descendants may be frozen:', m.target);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
The scroll container must be identified, not just the animated element: The attack is applied to the container — the element defining the view-timeline-name — not to the button. Audits that only check the button's animation-timeline value see the correct reference name and pass. The attack is invisible from the button's computed styles. Only an ancestor walk that verifies the named timeline is actually provided by some element in the DOM tree reveals the mismatch.
Findings summary
SkillAudit performs ancestor walks to match animation-timeline references to their view-timeline-name providers, monitors scroll containers for cascade overrides that change timeline names, and watches for mousedown-triggered timeline mutations. Run a free audit on your MCP server.