Curtain image revealWeb · App
Target
Implement the "Reading progress bar" effect in vanilla HTML/CSS/JS. Context: A thin bar at the top fills from 0 to 100% as the article scrolls, driven by scroll position. Trigger: the page scrolls; progress is bound to scroll position, not time. Timeline: scroll-progress-based (map scroll position to animation progress; no fixed duration). Motion tokens (normal): - scaleX: 0 → 1 - scroll range: 0% (top of scroller) → 100% (bottom of scroller) - easing: linear - note: transform-origin left; bar height 3px Motion tokens (prefers-reduced-motion): - scaleX: 0 → 1 - scroll range: 0% → 100% - easing: linear - note: scroll-bound motion is user-driven, so it stays; only remove any smoothing Technique: prefer CSS `animation-timeline: scroll()/view()` inside `@supports (animation-timeline: scroll())`, with a JS fallback; GSAP (ScrollTrigger where relevant) is acceptable as an alternative. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - bar uses transform: scaleX, never width - works when the scroller is a nested element, not only the document
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-reading-progress-bar" data-fx-root>
<div class="fx-bar" aria-hidden="true"></div>
<div class="fx-scroller" tabindex="0" aria-label="Scrollable article">
<div class="fx-article">
<div class="fx-h"></div>
<div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div>
<div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div>
<div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div>
<div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div>
</div>
</div>
</div>
demo.css
.fx-reading-progress-bar { position: relative; width: 100%; height: 100%; background: #fff; timeline-scope: --fx-article; }
.fx-reading-progress-bar .fx-bar { position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--fx-accent, #4f46e5); transform-origin: 0 50%; transform: scaleX(0); z-index: 1; }
.fx-reading-progress-bar .fx-scroller { position: absolute; inset: 0; overflow-y: auto; scroll-timeline: --fx-article block; }
.fx-reading-progress-bar .fx-article { display: flex; flex-direction: column; gap: 12px; padding: 28px 24px; min-height: 260%; }
.fx-reading-progress-bar .fx-h { height: 20px; width: 60%; border-radius: 6px; background: var(--fx-strong, #d4d4d8); margin-bottom: 8px; }
.fx-reading-progress-bar .fx-p { height: 10px; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
.fx-reading-progress-bar .fx-short { width: 70%; margin-bottom: 10px; }
@supports (animation-timeline: scroll()) {
.fx-reading-progress-bar.use-css .fx-bar { animation: fx-rp-grow linear both; animation-timeline: --fx-article; }
@keyframes fx-rp-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}
demo.js
// CSS scroll-driven animation when supported; otherwise a 3-line scroll listener fallback.
export default function mount(root) {
const bar = root.querySelector('.fx-bar');
const scroller = root.querySelector('.fx-scroller');
const supported = CSS.supports('animation-timeline: scroll()');
root.dataset.fxMode = supported ? 'css' : 'js-fallback';
const onScroll = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
bar.style.transform = `scaleX(${max > 0 ? scroller.scrollTop / max : 0})`;
};
return {
play() {
if (supported) { root.classList.add('use-css'); bar.style.transform = ''; }
else { scroller.addEventListener('scroll', onScroll, { passive: true }); onScroll(); }
},
stop() {
root.classList.remove('use-css');
scroller.removeEventListener('scroll', onScroll);
bar.style.transform = 'scaleX(0)';
},
};
}
demo.gsap.js
// GSAP ScrollTrigger variant. The host loads spec.implementations[].external before calling mount().
export default function mount(root) {
const bar = root.querySelector('.fx-bar');
const scroller = root.querySelector('.fx-scroller');
let tween;
return {
play() {
const { gsap, ScrollTrigger } = window;
if (!gsap || !ScrollTrigger) { root.dataset.fxMode = 'gsap-missing'; return; }
gsap.registerPlugin(ScrollTrigger);
root.dataset.fxMode = 'gsap';
tween = gsap.fromTo(bar, { scaleX: 0 }, {
scaleX: 1, ease: 'none',
scrollTrigger: { scroller, trigger: scroller.firstElementChild, start: 'top top', end: 'bottom bottom', scrub: true },
});
},
stop() { tween?.scrollTrigger?.kill(); tween?.kill(); tween = undefined; bar.style.transform = 'scaleX(0)'; },
};
}
Inspired by developer.chrome.com — Chrome scroll-driven animations guide. The preview is an original re-creation; the effect name belongs to its author.
Named `scroll-timeline` on the nested scroller so the demo works inside a card, not only on `document`. Fallback: scroll listener writes `scaleX` directly; GSAP variant uses `scrub: true` with the scroller option.
Added 2026-11-09 · Report an issue