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.
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) orinline(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) orinline - 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:
| Range | Meaning |
|---|---|
entry | Element entering the scrollport |
exit | Element leaving the scrollport |
cover | Element overlapping the scrollport |
contain | Element fully inside the scrollport |
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);
}
}Pattern 3: Horizontal Scroll Gallery
.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%;
}
}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
-
Use
lineartiming for scroll-linked animations: Scroll timelines map 1:1 with scroll position. Using non-linear easing (likeease) can create confusing acceleration effects. Uselinearfor scroll-linked animations andease-outfor view-based reveals. -
Set
animation-fill-mode: both: Withoutboth, elements may flash to their final state on page load. Thebothfill mode ensures elements start in theirfromstate. -
Use
@supportsfor progressive enhancement: Not all browsers support scroll-driven animations. Always provide a fallback for unsupported browsers:
@supports (animation-timeline: scroll()) {
/* Scroll animation styles */
}-
Prefer
view()overscroll()for element reveals:view()automatically handles enter/exit tracking.scroll()is better for document-level progress indicators. -
Use
animation-rangeto fine-tune timing: Don't let the full animation play over the entire scroll range. Useentry 0% entry 40%to make reveals snappy and responsive. -
Avoid animating layout properties: Stick to
transform,opacity, and custom properties for compositor-thread performance. Animatingwidth,height, ormargintriggers layout and may cause jank. -
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.
-
Use
will-changesparingly: The browser optimizes scroll-driven animations automatically. Addingwill-changeto many elements wastes GPU memory.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using ease with scroll() timeline | Animation accelerates/decelerates with scroll | Use linear timing for scroll-linked animations |
Missing animation-fill-mode: both | Element flashes to final state on load | Add both to animation-fill-mode |
Animating top/left instead of transform | Layout thrashing, poor performance | Use transform: translate() instead |
Not providing @supports fallback | Content invisible in unsupported browsers | Always provide a non-animated fallback |
Overlapping scroll() and view() on same element | Conflicting timeline sources | Use one timeline per element |
Forgetting animation-range | Animation plays over entire scroll range | Set 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
| Feature | CSS Scroll-Driven | GSAP ScrollTrigger | Intersection Observer | Scroll Event JS |
|---|---|---|---|---|
| JavaScript required | No | Yes | Yes | Yes |
| Compositor-thread | Yes | Partial | No | No |
| Bundle size | 0 KB | ~30 KB | 0 KB (native) | Varies |
| Scroll-linked timing | Native | Manual | Manual | Manual |
| Viewport tracking | Native view() | Manual | Native | Manual |
| Browser support | 93%+ (2025) | Universal | Universal | Universal |
| Ease of use | CSS-only | High (API) | Medium | Low |
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-descriptionBuilding 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.
Staying Current with Industry Trends
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:
- Use
scroll()for document-level progress — reading bars, hero parallax, sticky nav transitions. - Use
view()for element reveals — fade-in sections, slide-in cards, counter animations. - Always use
animation-fill-mode: bothto prevent initial state flashing. - Use
lineartiming for scroll-linked animations andease-outfor view-based reveals. - Set explicit
animation-rangeto control when animations play within the scroll range. - Provide
@supportsfallbacks 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.