Scroll effects with public CSS spec — animation-timeline: scroll() / view() + animation-range — not a single line of JavaScript.
WHAT
Purpose and context
- Purpose: add scroll effects (progress bar, reveal-on-view, subtle parallax) with pure CSS
animation-timelineas progressive enhancement — browsers without support or with reduce-motion enabled still see fully static content. - Trigger (when to use): the user says "css scroll driven animation", "animation-timeline", "scroll animation without JS", "pure CSS page scrolling", "scroll-driven CSS", or /css-scroll-driven-native; or wants to add 0-JS "smoothness" on top of
scroll-effects. Choose per the table "When to choose this vsscroll-effects" (Reference section). - Non-goals: not the primary mechanism when you need reliable multi-browser production behavior now (that's
scroll-effects, JS IntersectionObserver); do not write JS; do not hardcode browser version numbers.
Mental model
timeline (scroll(root) | view()) + animation-range → keyframes; toàn bộ nằm trong @supports → @media not (prefers-reduced-motion: reduce); ngoài hai khối đó = trạng thái tĩnh hiện đủ nội dung.
Input and output contract
| Field | Required? | Meaning | |
|---|---|---|---|
| In | needed mechanism (progress / reveal / parallax) | Yes | choose 1–3 of the 3 mechanisms |
| In | design.md of the project |
No | if present, adjust color/spacing tokens to it |
| Out | CSS (+ minimal markup) adapted from assets/demo.css / assets/demo.html |
Yes | all animations in @supports + reduce-motion guard |
| Out | verify evidence | Yes | 0%/50%/100% scroll screenshots + reduce-motion screenshot via playwright-verify |
Rules and capabilities
Rules (MANDATORY):
- RULE-01 (MUST): Always have a fallback
@supports: every animation lives inside@supports (animation-timeline: scroll())or@supports (animation-timeline: view()). OUTSIDE the@supportsblock is the default STATIC state — the element shows full content, with no animation, NEVER hidden/opacity:0 as the default. If writingopacity:0for a reveal effect, that value may ONLY be set inside@supports, not in the base rule. - RULE-02 (MUST):
prefers-reduced-motion: wrap the animation in@media not (prefers-reduced-motion: reduce)(nested inside@supports, seedemo.css). When the user enables reduce-motion → every element returns to the static state exactly like a browser without support. - RULE-03 (MUST): Do NOT hardcode version numbers in code or comments — check caniuse.com/css-scroll-driven-animations each actual use (supporting table in the Reference section).
- RULE-04 (MUST):
animation-durationmust still be declared (1msor any value >0) even though the actual duration is controlled by the timeline — some engines require this value to trigger the animation. - RULE-05 (MUST): Verify with
playwright-verifyon the Chromium browser (CDP supports scroll-driven animations by default): capture screenshots at 0%/50%/100% scroll, assert the progress bar/scaleX and card opacity change according to scroll position; at the same time capture additional ones withprefers-reduced-motion: reduceemulation (PlaywrightemulateMedia) to assert NO animation but the content still shows fully. - Capabilities: read asset skill +
design.md; write CSS/HTML into the target project; run the headless Chromium browser with media emulation.
Failure boundaries
- Requirement "must run reliably on every browser immediately" → clarify/switch
scroll-effectsas the base, this skill only overrides. - Content is hidden when
@supportsis wrong or reduce-motion is on → failed (violates RULE-01/02), fix before delivery. - 0%/50%/100% images are identical on Chromium → failed (animation not triggered, usually missing
animation-duration).
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | judgment | requirement + browser target | Choose this skill or scroll-effects according to the comparison table; check caniuse |
decision | need multi-browser reliability → B01 |
| W02 | deterministic | assets/demo.* |
Read assets/demo.html + assets/demo.css, take the needed mechanism |
snippet | — |
| W03 | effect | snippet, design.md |
ADAPT color/spacing tokens; put the animation in @supports + reduce-motion |
CSS in the project | — |
| W04 | deterministic | page already attached | Verify RULE-05: capture 0/50/100% + emulateMedia reduce |
images + assert | assert red → fix, repeat W04 |
Branches
| ID | Kind | Guard | Behavior | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | user_optional | need reliable multi-browser support but still want smoother 0-JS | combine: scroll-effects as the base, this skill overrides when the browser supports it |
only need Chromium → skip | W02 |
Validation and stopping
Deterministic assert at W04 (scaleX/opacity change with scroll position; reduce-motion has no animation, content is sufficient). Needs eyes: smoothness/design fit. Stop when both image types pass; 2 rounds still red → report to user with images.
Examples
- Positive: "add a reading progress bar with pure CSS" →
.csdn-progresswithanimation-timeline: scroll(root)in@supports+ reduce-motion guard → images at 0%/50%/100% for scaleX 0 → ~0.5 → 1. - Boundary/failure: reveal card placed
opacity:0in the base rule → on Firefox, which does not yet support it, the card disappears → violates RULE-01, moveopacity:0into@supportsthen verify again with reduce-motion.
Reference — When to choose this vs scroll-effects
css-scroll-driven-native (this skill) |
scroll-effects |
|
|---|---|---|
| JS | 0 lines | IntersectionObserver + rAF |
| Browser support | Chrome/Edge good; Firefox/Safari rolling out, may not be enabled by default | All modern browsers |
| When to use | Progressive enhancement — adds "smoothness" for browsers that already support it, NOT the main mechanism if it needs to run reliably in multi-browser production right away | The main mechanism when it needs to run reliably right away, without waiting for rollout |
Recommendation: use this skill inside @supports (animation-timeline: scroll()), static fallback (no animation, no hidden content) — or combine both: scroll-effects as the base, this skill overrides when the browser supports it.
⚠️ Check real support before using in production at caniuse.com/css-scroll-driven-animations — don't trust the hard numbers written in this document, the API is rolling out fast and the support table may have changed.
Current browser support table (recorded at the time of writing the skill, 2026-09 — may have changed, always check yourself):
| Browser | animation-timeline: scroll() |
animation-timeline: view() |
|---|---|---|
| Chrome / Edge | Yes | Yes |
| Firefox | Behind flag / rolling out | Behind flag / rolling out |
| Safari | Rolling out, not yet stable | Rolling out, not yet stable |
Reference — Assets
assets/demo.html + assets/demo.css — 3 demo mechanisms, read then ADAPT (change color/spacing tokens according to the project's design.md if any). Open directly in a supported browser (recent Chrome/Edge) to see it actually run; unsupported browsers will automatically fall back to the static state.
Reference — 3 mechanisms (minimal markup + mechanism)
1. Progress bar based on page scroll
A bar fixed at the top, its length following the % of the whole page scrolled.
<div class="csdn-progress"></div>
.csdn-progress {
animation-name: progress-scale;
animation-duration: 1ms; /* giá trị bất kỳ để kích hoạt engine — thời lượng thật do timeline điều khiển */
animation-timeline: scroll(root);
animation-fill-mode: both;
}
@keyframes progress-scale {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
scroll(root) tied to the scroll progress of the whole page — no need for a scroll listener, no need to compute scrollY / scrollHeight by hand.
2. Reveal-on-view when an element enters the viewport
<article class="csdn-card">…</article>
.csdn-card {
animation-name: reveal-in;
animation-timeline: view();
animation-range: entry 0% cover 40%;
animation-fill-mode: both;
}
view() is the individual timeline of EACH element based on its position in its own viewport — completely replacing IntersectionObserver. animation-range: entry 0% cover 40% means the animation runs from when the element starts entering the viewport (entry 0%) until it has "covered" 40% of the distance through the viewport.
3. Light parallax using view()
Background and content use different animation-range values on the same timeline view() → creating the feeling of layers drifting at different speeds, without needing to manually calculate scrollY .
.parallax-bg { animation-timeline: view(); animation-range: cover 0% cover 100%; } /* trôi chậm, suốt cả quãng */
.parallax-content { animation-timeline: view(); animation-range: entry 0% cover 35%; } /* chạy nhanh, chỉ đoạn đầu */
See assets/demo.css section 3 for the full keyframes.
Origin
- In addition to
skills/scroll-effects(JS-based, runs in all browsers), a 0-JS option for browsers that already support it — the two skills do NOT replace each other, use them according to the "When to choose this one" table above.