Jaconir

CSS Scroll Animations Without JavaScript: Complete Guide With Examples

March 23, 2026
9 min read

Scroll-triggered animations used to require JavaScript — either a library like AOS or GSAP, or a custom Intersection Observer implementation. That changed with the introduction of CSS scroll-driven animations, now supported natively in Chrome 115+, Edge 115+, and progressively in Firefox. You can now trigger and control animations entirely from CSS, with zero JavaScript, using the animation-timeline and animation-range properties. This guide covers every technique from modern pure-CSS to widely-supported fallback approaches, with copy-ready examples throughout.

Build your scroll animation CSS visually without writing a line of code: Jaconir Scroll Animation Generator — configure your effect and copy the generated CSS directly.

The Two Pure-CSS Scroll Animation Approaches

1. scroll() — Scroll Progress Timeline

Links an animation to the overall page scroll position. The animation plays from start to end as the user scrolls from top to bottom of the page. Useful for progress indicators and parallax effects.

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

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: #000;
  transform-origin: left;
  animation: progressBar linear;
  animation-timeline: scroll();
}

This creates a reading progress bar that fills as the user scrolls down — entirely in CSS, no JavaScript.

2. view() — Element View Timeline

Links an animation to when a specific element enters and exits the viewport. This is the "animate on scroll" pattern — elements fade in, slide up, or scale as they scroll into view.

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.reveal {
  animation: fadeInUp 0.6s ease forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 35%;
}

animation-timeline: view() — ties the animation to the element's visibility in the viewport.

animation-range: entry 0% entry 35% — the animation plays while the element moves from 0% to 35% into view. Once the element is 35% visible, the animation is complete and stays in the final state.

animation-range Explained

The animation-range property controls exactly when within the scroll timeline the animation plays. The syntax:

animation-range: [start-phase] [start-%] [end-phase] [end-%];

The phases:

  • entry — while the element is entering the viewport (scrolling in from the bottom)
    • exit — while the element is leaving the viewport (scrolling out at the top)
    • contain — while the element is fully contained within the viewport
    • cover — from when the element first touches the viewport to when it fully leaves
/* Animate in as element enters viewport */
animation-range: entry 0% entry 40%;

/* Animate out as element exits viewport */
animation-range: exit 0% exit 100%;

/* Animate while element is fully in view */
animation-range: contain 0% contain 100%;

/* Full lifecycle — in and out */
animation-range: cover 0% cover 100%;

Common Scroll Animation Patterns — Pure CSS

Fade In

@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.fade-in {
  animation: fadeIn 0.7s ease forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}

Slide Up

@keyframes slideUp {
  from {
    opacity: 0;
    transform: translateY(60px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.slide-up {
  animation: slideUp 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 35%;
}

Slide In From Left

@keyframes slideInLeft {
  from {
    opacity: 0;
    transform: translateX(-60px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

.slide-left {
  animation: slideInLeft 0.6s ease-out forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 35%;
}

Scale In

@keyframes scaleIn {
  from {
    opacity: 0;
    transform: scale(0.8);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

.scale-in {
  animation: scaleIn 0.5s ease-out forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}

Rotate In

@keyframes rotateIn {
  from {
    opacity: 0;
    transform: rotate(-10deg) scale(0.9);
  }
  to {
    opacity: 1;
    transform: rotate(0deg) scale(1);
  }
}

.rotate-in {
  animation: rotateIn 0.7s ease-out forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

Reading Progress Bar

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

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: #111;
  transform-origin: left center;
  animation: grow linear;
  animation-timeline: scroll(root);
  z-index: 1000;
}

Parallax Image

@keyframes parallax {
  from { transform: translateY(-20px); }
  to   { transform: translateY(20px); }
}

.parallax-image {
  animation: parallax linear;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

Staggered Animations Without JavaScript

To stagger multiple elements so they animate in sequence, use animation-delay with nth-child:

.card {
  animation: fadeInUp 0.6s ease forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 100ms; }
.card:nth-child(3) { animation-delay: 200ms; }
.card:nth-child(4) { animation-delay: 300ms; }

For dynamic lists with unknown lengths, use a CSS custom property approach:

.card {
  animation: fadeInUp 0.6s ease forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
  animation-delay: calc(var(--index, 0) * 100ms);
}

/* Set --index in HTML: style="--index: 0", style="--index: 1" etc. */
/* Or set it with a short JS loop — just index assignment, no animation logic */

Browser Support and Fallbacks

animation-timeline and animation-range are supported in:

  • Chrome 115+ ✅
    • Edge 115+ ✅
    • Firefox 110+ (behind flag), Firefox 132+ (enabled by default) ✅
    • Safari — not yet supported ❌

Safari is the notable gap. For full browser coverage, wrap the scroll-driven animation in a @supports block and provide a fallback:

/* Fallback — element is visible immediately in unsupported browsers */
.reveal {
  opacity: 1;
  transform: none;
}

/* Progressive enhancement — scroll animation only where supported */
@supports (animation-timeline: view()) {
  .reveal {
    opacity: 0;
    transform: translateY(40px);
    animation: fadeInUp 0.6s ease forwards;
    animation-timeline: view();
    animation-range: entry 0% entry 35%;
  }
}

This pattern ensures users on Safari see the content normally (never hidden) while users on Chrome and Edge get the animated experience. This is progressive enhancement — a core web development principle.

Full Implementation Example

A complete page section with multiple elements animating on scroll, no JavaScript:

<!-- HTML -->
<section class="features">
  <h2 class="reveal slide-up">Features</h2>
  <div class="card-grid">
    <div class="card reveal" style="--index: 0">Card 1</div>
    <div class="card reveal" style="--index: 1">Card 2</div>
    <div class="card reveal" style="--index: 2">Card 3</div>
  </div>
</section>

/* CSS */
@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Fallback: always visible */
.reveal {
  opacity: 1;
  transform: none;
}

/* Enhanced: scroll-driven where supported */
@supports (animation-timeline: view()) {
  .reveal {
    opacity: 0;
    transform: translateY(40px);
    animation: fadeInUp 0.6s ease forwards;
    animation-delay: calc(var(--index, 0) * 120ms);
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

/* Always respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .reveal {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}

Performance: Why CSS Scroll Animations Are Faster Than JS

CSS scroll-driven animations run on the browser's compositor thread — separate from the main JavaScript thread. This means:

  • They cannot be blocked by JavaScript execution or long tasks
    • They maintain 60fps even when the main thread is busy
    • No scroll event listener overhead — no function calls per scroll frame
    • No layout recalculation if you only animate opacity and transform The Intersection Observer approach (the JS fallback) is also highly performant because it uses a browser API rather than a scroll event listener. What you should avoid is the old pattern of attaching a window.addEventListener('scroll', handler) that runs on every scroll frame — this was the original source of scroll animation jank.

When You Still Need JavaScript

Pure CSS scroll animations cover most use cases, but JavaScript is still needed for:

  • Complex sequencing: Animations that depend on each other completing, or user interactions mid-animation
    • Dynamic values: Animation parameters that change based on data (e.g. a chart that animates based on fetched values)
    • Safari support today: If you need the animation effect (not just the fallback) in Safari, Intersection Observer with a class toggle is still the most reliable cross-browser approach
    • Physics-based animation: Spring animations, momentum, bounce — these require JS libraries like Motion or GSAP

FAQ

What is the difference between animation-timeline: scroll() and animation-timeline: view()?

scroll() links the animation to the scroll container's overall scroll progress — typically the whole page. Used for progress indicators and backgrounds that animate as the page scrolls. view() links the animation to a specific element's position within the viewport — used for reveal animations on individual elements.

Do I need to set opacity: 0 on elements before they animate in?

Yes — without setting the initial state, the element is visible before the animation runs, then jumps to the animated state. Always define the from state in your @keyframes and ensure the element starts in that state. With the @supports fallback pattern shown above, unsupported browsers never see the hidden state.

Why is my scroll animation not working in Safari?

animation-timeline is not yet supported in Safari. Use the @supports progressive enhancement pattern and provide a visible fallback for Safari users. For full Safari support of the animated effect, use the Intersection Observer approach with a class toggle.

Can I use scroll animations with CSS frameworks like Tailwind?

Yes — add the animation properties as custom CSS alongside your Tailwind classes. Tailwind doesn't include scroll-driven animation utilities yet, so these need to be written in a custom CSS block or @layer.

Conclusion

CSS scroll-driven animations are the cleanest, most performant way to add scroll reveals to a webpage — no dependencies, no event listeners, no library to maintain. Use animation-timeline: view() for element reveals, wrap in @supports for Safari fallback, and always include prefers-reduced-motion. That's the complete production-ready pattern.

Generate your scroll animation CSS: Jaconir Scroll Animation Generator →