MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

CSS Scroll-Driven Animations: Scroll and View Timelines

Create scroll animations: scroll() and view() timelines, progress-driven effects.

CSSAnimationsScrollFrontend

By MinhVo

Introduction

Scroll-based animations — parallax effects, progress bars, reveal-on-scroll, and sticky element transitions — are among the most popular interactions on the modern web. Until now, implementing them required JavaScript libraries like GSAP ScrollTrigger, Intersection Observer, or manual scroll event listeners that constantly query the DOM and update element styles. These approaches are fragile, often janky, and difficult to maintain.

CSS Scroll-Driven Animations change everything. The specification introduces two new timeline types — scroll() and view() — that let you tie any CSS animation to scroll progress or element visibility, entirely in CSS. No JavaScript, no event listeners, no requestAnimationFrame. The browser handles the animation synchronization natively, running on the compositor thread for buttery-smooth 60fps performance even on low-end devices.

This guide covers both timeline types, demonstrates practical patterns for real-world scroll interactions, and shows how to replace JavaScript scroll libraries with pure CSS.

CSS Scroll-Driven Animations illustration

Understanding Scroll-Driven Animations: Core Concepts

The scroll() Timeline

The scroll() function creates a timeline that progresses as a scroll container scrolls. The animation keyframes map to scroll positions: 0% progress = scroll start, 100% progress = scroll end.

.progress-bar {
  animation: grow-width linear;
  animation-timeline: scroll();
}
 
@keyframes grow-width {
  from { width: 0%; }
  to { width: 100%; }
}

The scroll() function accepts two optional arguments:

  • Scroller: nearest (default), self, root, or a specific element
  • Axis: block (default, vertical) or inline (horizontal)
/* Animate based on the nearest scroll container's vertical scroll */
animation-timeline: scroll(nearest block);
 
/* Animate based on the element's own horizontal scroll */
animation-timeline: scroll(self inline);
 
/* Animate based on the document's root scroller */
animation-timeline: scroll(root block);

The view() Timeline

The view() function creates a timeline that progresses as an element enters and exits the scrollport (the visible area). The animation starts when the element first becomes visible and completes when it fully exits.

.reveal {
  animation: fade-in linear;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}
 
@keyframes fade-in {
  from { opacity: 0; transform: translateY(50px); }
  to { opacity: 1; transform: translateY(0); }
}

The view() function accepts optional arguments:

  • Axis: block (default) or inline
  • Inset: How much to inset the scrollport boundaries
animation-timeline: view(block);
animation-timeline: view(inline 100px);

Animation Ranges

animation-range controls which portion of the timeline the animation plays:

/* Play during the entry phase (element entering the viewport) */
animation-range: entry 0% entry 100%;
 
/* Play during the cover phase (element moving through the viewport) */
animation-range: cover 0% cover 50%;
 
/* Shorthand */
animation-range: entry; /* Full entry range */
animation-range: cover; /* Full cover range */

Available range names:

RangeMeaning
entryElement entering the scrollport
exitElement leaving the scrollport
coverElement overlapping the scrollport
containElement fully inside the scrollport

Timeline diagram

Architecture and Design Patterns

Pattern 1: Reading Progress Bar

A progress bar at the top of the page that tracks document scroll:

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: var(--brand);
  transform-origin: left;
  animation: scale-x linear;
  animation-timeline: scroll(root);
}
 
@keyframes scale-x {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Pattern 2: Fade-In on Scroll

.fade-in-section {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}
 
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
.gallery {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
 
.gallery-item {
  flex: 0 0 100%;
  scroll-snap-align: start;
  animation: slide-in linear;
  animation-timeline: view(inline);
  animation-range: entry 0% cover 30%;
}
 
@keyframes slide-in {
  from { opacity: 0; scale: 0.8; }
  to { opacity: 1; scale: 1; }
}

Pattern 4: Sticky Navigation Background

.nav {
  position: sticky;
  top: 0;
  animation: nav-bg linear;
  animation-timeline: scroll();
  animation-range: 0px 200px;
}
 
@keyframes nav-bg {
  from { background: transparent; box-shadow: none; }
  to { background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
}

Step-by-Step Implementation

Step 1: Add a Reading Progress Bar

<div class="reading-progress" aria-hidden="true"></div>
<article>
  <!-- Long article content -->
</article>
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: var(--brand);
  transform-origin: left;
  z-index: 1000;
  animation: scale-x linear;
  animation-timeline: scroll(root);
}
 
@keyframes scale-x {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Step 2: Animate Sections on Scroll

<section class="animate-on-scroll">
  <h2>Section Title</h2>
  <p>Content that fades in as you scroll.</p>
</section>
.animate-on-scroll {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 40%;
}
 
@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(60px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Step 3: Create a Parallax Effect

.parallax-layer {
  animation: parallax-shift linear;
  animation-timeline: scroll();
  animation-range: 0% 100%;
}
 
@keyframes parallax-shift {
  from { transform: translateY(0); }
  to { transform: translateY(-200px); }
}

Step 4: Animate a Sticky Sidebar Table of Contents

.toc {
  position: sticky;
  top: 2rem;
}
 
.toc-item {
  animation: highlight linear;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}
 
@keyframes highlight {
  0%, 100% { color: #666; font-weight: 400; }
  50% { color: var(--brand); font-weight: 600; }
}

Step 5: Combine with animation-timing-function

/* Use easing for more natural scroll animations */
.parallax {
  animation: parallax linear;
  animation-timeline: scroll();
}
 
/* Non-linear easing for reveal effects */
.reveal {
  animation: reveal ease-out;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}

Step 6: Progressive Enhancement with @supports

/* Fallback: always visible */
.reveal {
  opacity: 1;
  transform: none;
}
 
/* Enhanced with scroll animations */
@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

Implementation workflow

Real-World Use Cases

Use Case 1: Product Page with Scroll Reveals

.product-feature {
  animation: slide-in-left linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}
 
.product-feature:nth-child(even) {
  animation-name: slide-in-right;
}
 
@keyframes slide-in-left {
  from { opacity: 0; transform: translateX(-80px); }
  to { opacity: 1; transform: translateX(0); }
}
 
@keyframes slide-in-right {
  from { opacity: 0; transform: translateX(80px); }
  to { opacity: 1; transform: translateX(0); }
}

Use Case 2: Hero Parallax

.hero-background {
  position: absolute;
  inset: 0;
  background: url('/hero.jpg') center / cover;
  animation: hero-parallax linear;
  animation-timeline: scroll();
  animation-range: 0px 600px;
}
 
@keyframes hero-parallax {
  from { transform: translateY(0) scale(1.1); }
  to { transform: translateY(-150px) scale(1); }
}
 
.hero-content {
  position: relative;
  animation: hero-fade linear;
  animation-timeline: scroll();
  animation-range: 0px 400px;
}
 
@keyframes hero-fade {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(-80px); }
}

Use Case 3: Counter Animation

.stat-number {
  animation: count-up linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
  font-variant-numeric: tabular-nums;
}
 
@keyframes count-up {
  from { --value: 0; opacity: 0; }
  to { --value: 100; opacity: 1; }
}

Use Case 4: Timeline Component

.timeline-item {
  animation: timeline-reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}
 
.timeline-line {
  animation: line-grow linear;
  animation-timeline: scroll();
  animation-range: 0% 100%;
  transform-origin: top;
}
 
@keyframes line-grow {
  from { transform: scaleY(0); }
  to { transform: scaleY(1); }
}

Best Practices for Production

  1. Use linear timing for scroll-linked animations: Scroll timelines map 1:1 with scroll position. Using non-linear easing (like ease) can create confusing acceleration effects. Use linear for scroll-linked animations and ease-out for view-based reveals.

  2. Set animation-fill-mode: both: Without both, elements may flash to their final state on page load. The both fill mode ensures elements start in their from state.

  3. Use @supports for progressive enhancement: Not all browsers support scroll-driven animations. Always provide a fallback for unsupported browsers:

@supports (animation-timeline: scroll()) {
  /* Scroll animation styles */
}
  1. Prefer view() over scroll() for element reveals: view() automatically handles enter/exit tracking. scroll() is better for document-level progress indicators.

  2. Use animation-range to fine-tune timing: Don't let the full animation play over the entire scroll range. Use entry 0% entry 40% to make reveals snappy and responsive.

  3. Avoid animating layout properties: Stick to transform, opacity, and custom properties for compositor-thread performance. Animating width, height, or margin triggers layout and may cause jank.

  4. Test on low-end devices: Scroll-driven animations are compositor-accelerated, but complex keyframes with many properties can still be expensive. Test on throttled devices.

  5. Use will-change sparingly: The browser optimizes scroll-driven animations automatically. Adding will-change to many elements wastes GPU memory.

Common Pitfalls and Solutions

PitfallImpactSolution
Using ease with scroll() timelineAnimation accelerates/decelerates with scrollUse linear timing for scroll-linked animations
Missing animation-fill-mode: bothElement flashes to final state on loadAdd both to animation-fill-mode
Animating top/left instead of transformLayout thrashing, poor performanceUse transform: translate() instead
Not providing @supports fallbackContent invisible in unsupported browsersAlways provide a non-animated fallback
Overlapping scroll() and view() on same elementConflicting timeline sourcesUse one timeline per element
Forgetting animation-rangeAnimation plays over entire scroll rangeSet explicit range for view-based animations

Performance Optimization

Scroll-driven animations run on the compositor thread, which is separate from the main JavaScript thread. This means:

  • No main thread blocking: Scroll animations do not compete with JavaScript for CPU time.
  • GPU-accelerated: Transform and opacity animations run on the GPU.
  • No layout/paint: Compositor-only properties (transform, opacity) avoid expensive layout recalculations.

For optimal performance:

/* Good: compositor-friendly properties */
@keyframes reveal {
  from { opacity: 0; transform: translateY(40px); }
  to { opacity: 1; transform: translateY(0); }
}
 
/* Avoid: triggers layout */
@keyframes bad-reveal {
  from { margin-top: 40px; }
  to { margin-top: 0; }
}

Comparison with Alternatives

FeatureCSS Scroll-DrivenGSAP ScrollTriggerIntersection ObserverScroll Event JS
JavaScript requiredNoYesYesYes
Compositor-threadYesPartialNoNo
Bundle size0 KB~30 KB0 KB (native)Varies
Scroll-linked timingNativeManualManualManual
Viewport trackingNative view()ManualNativeManual
Browser support93%+ (2025)UniversalUniversalUniversal
Ease of useCSS-onlyHigh (API)MediumLow

Advanced Patterns

Combining Scroll and View Timelines

Use scroll() for the reading progress bar and view() for section reveals on the same page:

.reading-progress {
  animation: scale-x linear;
  animation-timeline: scroll(root);
}
 
.section {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}

Scroll-Linked Color Transition

.hero {
  animation: bg-shift linear;
  animation-timeline: scroll();
  animation-range: 0px 500px;
}
 
@keyframes bg-shift {
  from { background-color: #1a1a2e; color: white; }
  to { background-color: #f8f9fa; color: #1a1a2e; }
}

Sticky Element with Scroll Progress

.sidebar-toc {
  position: sticky;
  top: 2rem;
}
 
.toc-progress {
  height: 2px;
  background: var(--brand);
  transform-origin: left;
  animation: progress linear;
  animation-timeline: scroll(nearest);
}
 
@keyframes progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Testing Strategies

import { test, expect } from '@playwright/test';
 
test('reading progress bar tracks scroll', async ({ page }) => {
  await page.goto('/article');
  const bar = page.locator('.reading-progress');
  
  // At top: progress should be 0
  const initialScale = await bar.evaluate(
    el => getComputedStyle(el).transform
  );
  expect(initialScale).toContain('0');
  
  // Scroll to middle
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2));
  await page.waitForTimeout(100);
  
  const midScale = await bar.evaluate(
    el => getComputedStyle(el).transform
  );
  expect(midScale).not.toBe(initialScale);
});
 
test('reveal animation triggers on scroll', async ({ page }) => {
  await page.goto('/demo');
  const section = page.locator('.animate-on-scroll').first();
  
  // Initially off-screen: should be invisible
  await expect(section).toHaveCSS('opacity', '0');
  
  // Scroll to element
  await section.scrollIntoViewIfNeeded();
  await page.waitForTimeout(200);
  
  // Should now be visible
  await expect(section).toHaveCSS('opacity', '1');
});

Future Outlook

CSS Scroll-Driven Animations are now baseline in Chrome, Edge, and Firefox, with Safari implementing the feature in 2025. The specification continues to evolve with potential additions including:

  • Scroll-linked custom properties: Animate custom properties directly based on scroll position.
  • Timeline scoping: Share timelines across elements for coordinated animations.
  • Scroll snap integration: Automatic animation timing with scroll snap points.

The Chrome DevTools team has added a scroll animation inspection panel that visualizes timeline progress and animation ranges, making debugging much easier than with JavaScript alternatives.

Cross-Browser Testing Strategy

Modern CSS features often have varying levels of browser support, making a systematic cross-browser testing strategy essential. Before using any CSS feature in production, verify its support status and implement appropriate fallbacks for browsers that haven't yet implemented it.

Progressive Enhancement with @supports

Use the @supports at-rule to provide fallback styles for browsers that don't support specific CSS features:

/* Base styles for all browsers */
.grid-container {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
 
.grid-container > * {
  flex: 1 1 300px;
}
 
/* Enhanced layout for browsers supporting CSS Grid */
@supports (display: grid) {
  .grid-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 1.5rem;
  }
 
  .grid-container > * {
    flex: none;
  }
}
 
/* Further enhancement with subgrid support */
@supports (grid-template-columns: subgrid) {
  .grid-container {
    grid-template-columns: subgrid;
  }
}

Visual Regression Testing

Implement visual regression testing to catch unintended layout shifts and styling changes. Tools like Percy, Chromatic, or Playwright's screenshot comparison can detect visual differences across browsers and screen sizes:

const { test, expect } = require('@playwright/test');
 
test('responsive layout matches design', async ({ page }) => {
  await page.goto('/components/dashboard');
 
  // Test at multiple viewport sizes
  for (const viewport of [
    { width: 375, height: 812, name: 'mobile' },
    { width: 768, height: 1024, name: 'tablet' },
    { width: 1440, height: 900, name: 'desktop' },
  ]) {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    await expect(page).toHaveScreenshot(
      `dashboard-${viewport.name}.png`,
      { maxDiffPixels: 100 }
    );
  }
});

Browser Compatibility Testing Matrix

Maintain a testing matrix that covers the browsers and versions your users actually use. Use analytics data to determine your browser support baseline, then configure tools like Browserslist to automatically handle polyfilling and prefixing:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

This data-driven approach ensures you're spending testing effort where it matters most, rather than trying to support every possible browser configuration.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

CSS Scroll-Driven Animations eliminate the need for JavaScript scroll libraries for the vast majority of scroll-based interactions. With scroll() timelines for document-level progress and view() timelines for element visibility tracking, you can build parallax effects, reveal animations, progress bars, and sticky transitions entirely in CSS.

Key takeaways:

  1. Use scroll() for document-level progress — reading bars, hero parallax, sticky nav transitions.
  2. Use view() for element reveals — fade-in sections, slide-in cards, counter animations.
  3. Always use animation-fill-mode: both to prevent initial state flashing.
  4. Use linear timing for scroll-linked animations and ease-out for view-based reveals.
  5. Set explicit animation-range to control when animations play within the scroll range.
  6. Provide @supports fallbacks for browsers that do not yet support the feature.

Start by adding a reading progress bar to your blog — it is five lines of CSS and demonstrates the power of scroll-driven animations immediately.