Curtain image revealWeb · App
Target
Implement the "Header condense on scroll" effect in vanilla HTML/CSS/JS. Context: A sticky header's title shrinks and a shadow fades in during the first 80px of scrolling. 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): - scale (title): 1 → 0.8 - opacity (shadow layer): 0 → 1 - scroll range: 0px → 80px of scroll - easing: linear - note: title transform-origin left center Motion tokens (prefers-reduced-motion): - opacity (shadow layer): 0 → 1 - scroll range: 0px → 80px - easing: linear Technique: prefer CSS `animation-timeline: scroll()/view()` inside `@supports (animation-timeline: scroll())`, with a JS fallback. 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 - header height never changes; only the title transform and a shadow overlay animate - fully reversible when scrolling back to top
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-header-condense-on-scroll" data-fx-root>
<div class="fx-scroller" tabindex="0" aria-label="Scrollable page">
<header class="fx-header"><span class="fx-shadow" aria-hidden="true"></span><span class="fx-title">Settings</span><span class="fx-dot" aria-hidden="true"></span></header>
<div class="fx-body"><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-header-condense-on-scroll { position: relative; width: 100%; height: 100%; background: #fff; timeline-scope: --fx-hc; }
.fx-header-condense-on-scroll .fx-scroller { position: absolute; inset: 0; overflow-y: auto; scroll-timeline: --fx-hc block; }
.fx-header-condense-on-scroll .fx-header { position: sticky; top: 0; z-index: 1; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; }
.fx-header-condense-on-scroll .fx-shadow { position: absolute; inset: 0; box-shadow: 0 6px 18px rgba(0,0,0,.10); border-bottom: 1px solid var(--fx-muted, #e4e4e7); opacity: 0; pointer-events: none; }
.fx-header-condense-on-scroll .fx-title { position: relative; font: 600 24px/1 Inter, system-ui, sans-serif; color: var(--fx-ink, #18181b); transform-origin: left center; }
.fx-header-condense-on-scroll .fx-dot { position: relative; width: 28px; height: 28px; border-radius: 50%; background: var(--fx-strong, #d4d4d8); }
.fx-header-condense-on-scroll .fx-body { display: flex; flex-direction: column; gap: 12px; padding: 20px; min-height: 220%; }
.fx-header-condense-on-scroll .fx-p { height: 10px; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
.fx-header-condense-on-scroll .fx-short { width: 60%; }
@supports (animation-timeline: scroll()) {
.fx-header-condense-on-scroll.use-css .fx-title { animation: fx-hc-title linear both; animation-timeline: --fx-hc; animation-range: 0 80px; }
.fx-header-condense-on-scroll.use-css .fx-shadow { animation: fx-hc-shadow linear both; animation-timeline: --fx-hc; animation-range: 0 80px; }
@keyframes fx-hc-title { to { transform: scale(.8); } }
@keyframes fx-hc-shadow { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .fx-header-condense-on-scroll.use-css .fx-title { animation: none; } }
}
demo.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), title = root.querySelector('.fx-title'), shadow = root.querySelector('.fx-shadow');
const supported = CSS.supports('animation-timeline: scroll()');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
root.dataset.fxMode = supported ? 'css' : 'js-fallback';
const onScroll = () => { const p = Math.min(1, scroller.scrollTop / 80); title.style.transform = reduced ? '' : `scale(${1 - 0.2 * p})`; shadow.style.opacity = String(p); };
return {
play() { if (supported) root.classList.add('use-css'); else { scroller.addEventListener('scroll', onScroll, { passive: true }); onScroll(); } },
stop() { root.classList.remove('use-css'); scroller.removeEventListener('scroll', onScroll); title.style.transform = shadow.style.opacity = ''; },
};
}
Inspired by tympanus.net — Codrops: navbar shadow on scroll. The preview is an original re-creation; the effect name belongs to its author.
`animation-range: 0 80px` on a `scroll()` timeline accepts lengths, so the whole effect completes in the first 80px regardless of page length. The shadow is a separate absolutely-positioned layer so we animate opacity, not box-shadow.
Added 2026-11-09 · Report an issue