Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Scroll-Driven Animations: Progress Bars and Reveals Without JS

C
Carlo Fontanos
· 2 min read

The reading-progress bar used to require a scroll listener recomputing percentages on every frame - main-thread work competing with the very scrolling it measures. The CSS version runs on the compositor and cannot jank:

.progress-bar {
    position: fixed;
    top: 0; left: 0;
    height: 3px;
    width: 100%;
    background: #4f46e5;
    transform-origin: left;

    animation: grow linear;
    animation-timeline: scroll();     /* progress = document scroll position */
}

@keyframes grow {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
}

animation-timeline: scroll() replaces the clock: instead of seconds, the animation's progress maps to scroll progress. Scroll halfway, the bar is half full - instantly, bidirectionally, with zero JavaScript.

view(): animate elements as they enter the viewport

.card {
    animation: fade-up linear both;
    animation-timeline: view();               /* this element's own visibility */
    animation-range: entry 0% entry 60%;      /* animate during the first 60% of entering */
}

@keyframes fade-up {
    from { opacity: 0; translate: 0 24px; }
    to   { opacity: 1; translate: 0 0; }
}

view() tracks the element's journey through the viewport, and animation-range picks which slice of that journey drives the keyframes. This is the scroll-reveal pattern - previously an IntersectionObserver plus class-toggling plus transition - as three declarations. Unlike the observer version, it's scrubbed: scroll back up and the animation reverses naturally.

Support requires a strategy (for now)

Chromium has shipped it since 115; Safari 26 arrived recently; Firefox still holds it behind a flag. That's real reach but not universal - so treat it strictly as enhancement:

@supports (animation-timeline: scroll()) {
    /* all scroll-driven rules live inside the guard */
}

The graceful fallback for reveals is "content is simply visible" - which is exactly what non-supporting browsers render if your keyframes animate from hidden with animation-fill both inside the guard. Never put the hidden state outside the @supports block, or Firefox users get invisible content. And wrap the whole indulgence in prefers-reduced-motion: no-preference - scroll-linked motion is precisely what vestibular users need switched off.

Where this beats JS on principle

Scroll listeners are inherently laggy - they run after the scroll happened, so linked effects trail by a frame or more on busy pages. Compositor-driven timelines are synchronous with the scroll itself: parallax that doesn't swim, progress that doesn't stutter. For anything more complex than CSS can express, the same timeline plugs into element.animate() via the timeline option (ScrollTimeline in JS), keeping the off-main-thread benefits with imperative control.

Start with the progress bar - it's five minutes, visibly delightful, and harmless everywhere it doesn't run.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading