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 Timeline and View Timeline

Create scroll-driven animations with pure CSS: scroll() and view() functions.

CSSScroll AnimationsFrontendAnimation

By MinhVo

Introduction

Scroll-driven animations have been a staple of web design for years, but implementing them has always required JavaScript. Libraries like GSAP ScrollTrigger, Locomotive Scroll, and ScrollMagic became essential tools because CSS alone could not link animations to scroll position. The CSS Scroll-Driven Animations specification changes this by introducing scroll() and view() timeline functions that let you synchronize any CSS animation with scrolling behavior — entirely without JavaScript.

The scroll() timeline ties animation progress to the scroll position of a container. The view() timeline ties animation progress to an element's visibility within the scrollport. Both run on the compositor thread, meaning they execute off the main thread for jank-free performance even during heavy scrolling. This guide dives deep into both timeline types, covers the animation-range property for fine-grained control, and demonstrates production-ready patterns that replace common JavaScript scroll libraries.

CSS Scroll-Driven Animations illustration

Understanding Scroll Timelines: Core Concepts

scroll() Timeline Fundamentals

The scroll() function creates a timeline that maps animation progress (0% → 100%) to a scroll container's scroll position (start → end):

.progress-indicator {
  width: 100%;
  height: 4px;
  background: var(--brand);
  transform-origin: left center;
  animation: fill linear;
  animation-timeline: scroll();
}
 
@keyframes fill {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

The animation progress is directly proportional to scroll position: when you have scrolled 50% of the container, the animation is at 50%.

scroll() Arguments

/* Default: nearest scroll container, block (vertical) axis */
animation-timeline: scroll();
 
/* Explicit scroller and axis */
animation-timeline: scroll(nearest block);   /* same as default */
animation-timeline: scroll(self block);      /* element's own scroll */
animation-timeline: scroll(root block);      /* document scroller */
animation-timeline: scroll(closest block);   /* closest ancestor scroll container */
animation-timeline: scroll(nearest inline);  /* horizontal scroll */

Named Timelines

You can name timelines with scroll-timeline-name and reference them with animation-timeline:

.scroll-container {
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
  overflow-y: auto;
}
 
.animated-child {
  animation: reveal linear both;
  animation-timeline: --my-scroller;
}

This decouples the animation from the scroll container's position in the DOM.

view() Timeline Fundamentals

The view() timeline tracks an element's position relative to the scrollport:

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

The view() timeline has distinct phases:

PhaseProgressMeaning
entry0% → 100%Element entering the scrollport
exit0% → 100%Element leaving the scrollport
cover0% → 100%Element overlapping the scrollport at all
contain0% → 100%Element fully inside the scrollport

Timeline phases diagram

Architecture and Design Patterns

Pattern 1: Staggered Card Reveals

.card {
  animation: card-reveal linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}
 
@keyframes card-reveal {
  from {
    opacity: 0;
    transform: translateY(60px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
.gallery-wrapper {
  overflow-x: auto;
  display: flex;
  gap: 1rem;
}
 
.gallery-item {
  flex: 0 0 300px;
  animation: gallery-scale linear;
  animation-timeline: view(inline);
  animation-range: entry 0% cover 50%;
}
 
@keyframes gallery-scale {
  from { scale: 0.85; opacity: 0.5; }
  to { scale: 1; opacity: 1; }
}

Pattern 3: Sticky Header with Scroll-Aware Background

.header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: transparent;
  animation: header-bg linear;
  animation-timeline: scroll();
  animation-range: 0px 200px;
}
 
@keyframes header-bg {
  from {
    background: transparent;
    backdrop-filter: none;
    box-shadow: none;
  }
  to {
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(12px);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
}

Pattern 4: Scroll-Linked Gradient Background

.hero {
  background: linear-gradient(
    180deg,
    var(--brand-dark) 0%,
    var(--brand) 50%,
    var(--brand-light) 100%
  );
  background-size: 100% 300%;
  background-position: 0% 0%;
  animation: gradient-shift linear;
  animation-timeline: scroll();
  animation-range: 0px 800px;
}
 
@keyframes gradient-shift {
  from { background-position: 0% 0%; }
  to { background-position: 0% 100%; }
}

Step-by-Step Implementation

Step 1: Create a Reading Progress Bar

<header class="progress-bar" aria-hidden="true"></header>
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--brand);
  transform-origin: 0% 50%;
  z-index: 9999;
  animation: progress-grow linear;
  animation-timeline: scroll(root);
}
 
@keyframes progress-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Step 2: Add Scroll Reveal to Content Sections

<article>
  <section class="scroll-reveal">
    <h2>First Section</h2>
    <p>Content that fades in as you scroll down.</p>
  </section>
  <section class="scroll-reveal">
    <h2>Second Section</h2>
    <p>More content that reveals on scroll.</p>
  </section>
</article>
.scroll-reveal {
  opacity: 0;
  transform: translateY(40px);
  animation: scroll-reveal linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 40%;
}
 
@keyframes scroll-reveal {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Step 3: Create a Parallax Hero

.hero {
  position: relative;
  height: 100vh;
  overflow: hidden;
}
 
.hero-image {
  position: absolute;
  inset: -20% 0;
  background: url('/hero.jpg') center / cover no-repeat;
  animation: parallax linear;
  animation-timeline: scroll(root);
  animation-range: 0px 100vh;
}
 
@keyframes parallax {
  from { transform: translateY(0); }
  to { transform: translateY(100px); }
}

Step 4: Build a Scroll-Aware Sidebar

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

Step 5: Animate a Horizontal Scroll Section

<section class="horizontal-scroll">
  <div class="scroll-track">
    <div class="scroll-item">1</div>
    <div class="scroll-item">2</div>
    <div class="scroll-item">3</div>
    <div class="scroll-item">4</div>
  </div>
</section>
.horizontal-scroll {
  overflow-x: auto;
  display: flex;
  gap: 2rem;
  padding: 2rem;
}
 
.scroll-item {
  flex: 0 0 80vw;
  height: 60vh;
  display: grid;
  place-items: center;
  font-size: 3rem;
  border-radius: 16px;
  background: var(--surface);
  
  animation: item-reveal linear both;
  animation-timeline: view(inline);
  animation-range: entry 0% cover 40%;
}
 
@keyframes item-reveal {
  from { scale: 0.8; opacity: 0; }
  to { scale: 1; opacity: 1; }
}

Step 6: Add Fallback for Older Browsers

/* Fallback: elements are always visible */
.scroll-reveal {
  opacity: 1;
  transform: none;
}
 
/* Enhanced: animated reveal when supported */
@supports (animation-timeline: view()) {
  .scroll-reveal {
    opacity: 0;
    transform: translateY(40px);
    animation: scroll-reveal linear both;
    animation-timeline: view();
    animation-range: entry 10% entry 40%;
  }
}

Implementation workflow

Real-World Use Cases

Use Case 1: Portfolio Site with Scroll Storytelling

.project-section {
  min-height: 100vh;
  display: grid;
  place-items: center;
}
 
.project-image {
  animation: image-zoom linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}
 
@keyframes image-zoom {
  from { scale: 1.2; }
  to { scale: 1; }
}
 
.project-text {
  animation: text-slide linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}
 
@keyframes text-slide {
  from { opacity: 0; transform: translateX(-60px); }
  to { opacity: 1; transform: translateX(0); }
}

Use Case 2: E-Commerce Product Showcase

.product-card {
  container-type: inline-size;
  animation: card-pop linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}
 
@keyframes card-pop {
  from { opacity: 0; transform: scale(0.9) translateY(30px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}
 
.product-price {
  animation: price-count linear both;
  animation-timeline: view();
  animation-range: entry 20% cover 50%;
}

Use Case 3: Blog with Animated Table of Contents

.toc {
  position: sticky;
  top: 6rem;
}
 
.toc-item {
  position: relative;
  padding-left: 1rem;
  color: #999;
  transition: color 0.3s;
}
 
/* Highlight TOC item when its section is in view */
.toc-item.active {
  color: var(--brand);
}
 
/* Use view() to detect which section is visible */
section[id] {
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

Best Practices for Production

  1. Use linear timing for scroll-linked animations: Scroll progress is linear by nature. Using easing functions like ease creates confusing acceleration effects that do not match the user's scroll speed.

  2. Set animation-fill-mode: both: Without this, elements may flash from their animated from state to their default state before the animation starts. both ensures the initial state is applied immediately.

  3. Use animation-range to control timing: Don't let reveals play over the entire scroll range. Short ranges like entry 0% entry 40% create snappy, responsive animations that feel natural.

  4. Prefer transform and opacity: These are compositor-friendly properties that do not trigger layout or paint. Animating margin, padding, width, or height causes jank.

  5. Use @supports for progressive enhancement: Always provide a non-animated fallback for browsers that do not support scroll-driven animations.

  6. Name your timelines for complex layouts: When the scroll container and the animated element are in different parts of the DOM, use scroll-timeline-name to connect them.

  7. Use view(inline) for horizontal galleries: The inline axis tracks horizontal scrolling, which is perfect for horizontal galleries and carousels.

  8. Combine scroll() and view() on the same page: Use scroll() for global progress indicators and view() for per-element reveals. They do not conflict.

Common Pitfalls and Solutions

PitfallImpactSolution
Using ease with scroll timelineAnimation feels jerky and disconnected from scrollUse linear timing function
Missing animation-fill-mode: bothElements flash to final state before animation startsAdd both fill mode
Animating non-compositor propertiesLayout thrashing, poor scroll performanceUse transform and opacity only
No @supports fallbackContent invisible in unsupported browsersAlways provide a fallback
Wrong animation-range valuesAnimation plays at wrong scroll positionTest with DevTools scroll animation panel
Forgetting scroll-timeline-axisNamed timeline defaults to blockSet axis: inline for horizontal galleries

Performance Optimization

Scroll-driven animations are compositor-accelerated by default. The browser handles all synchronization between scroll position and animation progress on the compositor thread, bypassing the main thread entirely.

For best performance:

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

Reduce the number of simultaneously animated elements. While compositor animations are efficient, animating hundreds of elements simultaneously can still strain GPU memory. Use animation-range to stagger reveals so only a few elements animate at any given scroll position.

Comparison with Alternatives

FeatureCSS Scroll-DrivenGSAP ScrollTriggerIntersection Observer
JavaScriptNoneRequiredRequired
ThreadCompositorMain + CompositorMain
Bundle size0 KB~30 KB0 KB (native)
Scroll-linked progressNativeAPIManual
Viewport trackingNative view()APINative (binary)
Easing controlCSS timing functionsGSAP easingN/A
Browser support93%+ (2025)UniversalUniversal

Advanced Patterns

Coordinated Multi-Element Animations

.hero-content {
  animation: hero-fade linear both;
  animation-timeline: scroll();
  animation-range: 0px 500px;
}
 
.hero-bg {
  animation: hero-parallax linear both;
  animation-timeline: scroll();
  animation-range: 0px 800px;
}
 
@keyframes hero-fade {
  from { opacity: 1; }
  to { opacity: 0; }
}
 
@keyframes hero-parallax {
  from { transform: translateY(0) scale(1.1); }
  to { transform: translateY(-200px) scale(1); }
}

Scroll-Linked Custom Properties with @property

@property --scroll-progress {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
 
.scroll-linked {
  animation: update-progress linear;
  animation-timeline: scroll();
}
 
@keyframes update-progress {
  from { --scroll-progress: 0%; }
  to { --scroll-progress: 100%; }
}
 
/* Use the custom property in any CSS property */
.gradient-bar {
  background: linear-gradient(
    to right,
    var(--brand) var(--scroll-progress),
    #e5e7eb var(--scroll-progress)
  );
}

Scroll Snap Integration

.snap-container {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  height: 100vh;
}
 
.snap-section {
  scroll-snap-align: start;
  height: 100vh;
  animation: snap-reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}

Testing Strategies

import { test, expect } from '@playwright/test';
 
test('scroll animation progresses with scroll position', async ({ page }) => {
  await page.goto('/demo');
  const bar = page.locator('.progress-bar');
  
  // Scroll to 25%
  await page.evaluate(() => {
    const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
    window.scrollTo(0, maxScroll * 0.25);
  });
  await page.waitForTimeout(100);
  
  const transform = await bar.evaluate(el => getComputedStyle(el).transform);
  // Should be approximately 25% scale
  expect(transform).not.toBe('none');
});
 
test('view animation triggers when element enters viewport', async ({ page }) => {
  await page.goto('/demo');
  const section = page.locator('.scroll-reveal').first();
  
  // Scroll until section is visible
  await section.scrollIntoViewIfNeeded();
  await page.waitForTimeout(300);
  
  const opacity = await section.evaluate(el => getComputedStyle(el).opacity);
  expect(parseFloat(opacity)).toBeGreaterThan(0.5);
});

Future Outlook

Safari shipped scroll-driven animations in version 18 (2025), making the feature available in all major browsers. The CSS Working Group is exploring extensions including:

  • Scroll-linked custom properties: Animate --var values directly from scroll position without @property registration.
  • Timeline-based @keyframes composition: Combine multiple timelines on a single element.
  • Scroll snap integration: Automatic timeline alignment with snap points.

Chrome DevTools now includes a dedicated Scroll-Driven Animations panel that visualizes timeline progress, animation ranges, and compositor status, making it significantly easier to debug than 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 bring native, compositor-accelerated scroll animations to the web platform. The scroll() timeline maps animation progress to scroll position for document-level effects, while the view() timeline tracks element visibility for per-element reveals.

Key takeaways:

  1. scroll() for global effects: Reading progress bars, parallax heroes, sticky header transitions.
  2. view() for element reveals: Fade-in sections, card animations, gallery item reveals.
  3. Always use animation-fill-mode: both to prevent initial state flashing.
  4. Use linear timing for scroll-linked animations to maintain natural feel.
  5. Set explicit animation-range to control when and how long animations play.
  6. Provide @supports fallbacks for graceful degradation.

Start with a reading progress bar — it is the simplest scroll-driven animation and immediately demonstrates the power and performance of the native approach.