Animations

Scroll-Triggered Animations with CSS and Intersection Observer

Dmitriy Hulak
Dmitriy Hulak
13 min read0 views

Scroll-Triggered Animations with CSS and Intersection Observer

Scroll-triggered animations add life to websites, guiding users through content with smooth, engaging transitions. Let's build performant scroll animations using the Intersection Observer API and CSS.

Why Intersection Observer?

Traditional scroll event listeners fire constantly, causing performance issues. Intersection Observer is:

  • Performant - Runs asynchronously, doesn't block the main thread
  • Battery-friendly - Only fires when visibility changes
  • Accurate - Precisely detects when elements enter/leave viewport

Basic Fade-In on Scroll

The simplest scroll animation - elements fade in when they enter the viewport.

HTML:

<div class="fade-in">Content fades in</div>
<div class="fade-in">More content</div>
<div class="fade-in">Even more</div>

CSS:

.fade-in {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.fade-in.visible { opacity: 1; transform: translateY(0); }

JavaScript:

const observerOptions = {
  threshold: 0.1,
  rootMargin: '0px 0px -100px 0px'
};

const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); } }); }, observerOptions);

document.querySelectorAll('.fade-in').forEach(el => { observer.observe(el); });

Staggered Animations

Animate multiple elements with a cascading delay:

CSS:

.stagger-item {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.5s ease, transform 0.5s ease;
}

.stagger-item.visible { opacity: 1; transform: translateY(0); }

.stagger-item:nth-child(1).visible { transition-delay: 0.1s; } .stagger-item:nth-child(2).visible { transition-delay: 0.2s; } .stagger-item:nth-child(3).visible { transition-delay: 0.3s; } .stagger-item:nth-child(4).visible { transition-delay: 0.4s; } .stagger-item:nth-child(5).visible { transition-delay: 0.5s; }

JavaScript (dynamic delays):

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry, index) => {
    if (entry.isIntersecting) {
      setTimeout(() => {
        entry.target.classList.add('visible');
      }, index * 100);
    }
  });
});

Slide-In from Sides

Create directional entrance animations:

CSS:

.slide-left {
  opacity: 0;
  transform: translateX(-100px);
  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}

.slide-right { opacity: 0; transform: translateX(100px); transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); }

.slide-left.visible, .slide-right.visible { opacity: 1; transform: translateX(0); }

Scale and Rotate

Add dimension with scale and rotation:

.scale-rotate {
  opacity: 0;
  transform: scale(0.8) rotate(-5deg);
  transition: all 0.6s ease;
}

.scale-rotate.visible { opacity: 1; transform: scale(1) rotate(0deg); }

Parallax Effect

Create depth with CSS custom properties and Intersection Observer:

HTML:

<div class="parallax-container">
  <div class="parallax-bg" data-speed="0.5"></div>
  <div class="parallax-content">Content here</div>
</div>

CSS:

.parallax-container {
  position: relative;
  overflow: hidden;
  height: 500px;
}

.parallax-bg { position: absolute; inset: 0; background: url('/image.jpg') center/cover; transform: translateY(var(--parallax-offset, 0)); will-change: transform; }

JavaScript:

const parallaxElements = document.querySelectorAll('[data-speed]');

window.addEventListener('scroll', () => { requestAnimationFrame(() => { parallaxElements.forEach(el => { const speed = parseFloat(el.dataset.speed); const rect = el.getBoundingClientRect(); const scrolled = window.pageYOffset; const offset = (scrolled - rect.top) * speed; el.style.setProperty('--parallax-offset', ${offset}px); }); }); });

Progressive Number Counter

Animate numbers when they scroll into view:

HTML:

<div class="counter" data-target="1250">0</div>

CSS:

.counter {
  font-size: 3rem;
  font-weight: 700;
  color: var(--primary);
}

JavaScript:

const animateCounter = (element) => {
  const target = parseInt(element.dataset.target);
  const duration = 2000;
  const increment = target / (duration / 16);
  let current = 0;

const updateCounter = () => { current += increment; if (current < target) { element.textContent = Math.floor(current); requestAnimationFrame(updateCounter); } else { element.textContent = target; } };

updateCounter(); };

const counterObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { animateCounter(entry.target); counterObserver.unobserve(entry.target); } }); }, { threshold: 0.5 });

document.querySelectorAll('.counter').forEach(counter => { counterObserver.observe(counter); });

Text Reveal Animation

Reveal text word-by-word or letter-by-letter:

HTML:

<div class="text-reveal">
  <span>Amazing</span>
  <span>scroll</span>
  <span>animations</span>
</div>

CSS:

.text-reveal span {
  display: inline-block;
  opacity: 0;
  transform: translateY(20px);
  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

.text-reveal.visible span { opacity: 1; transform: translateY(0); }

.text-reveal.visible span:nth-child(1) { transition-delay: 0.1s; } .text-reveal.visible span:nth-child(2) { transition-delay: 0.2s; } .text-reveal.visible span:nth-child(3) { transition-delay: 0.3s; }

Advanced: Progress Indicator

Show scroll progress with a dynamic bar:

HTML:

<div class="progress-bar"></div>

CSS:

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: linear-gradient(90deg, #667eea, #764ba2);
  width: var(--scroll-progress, 0%);
  transition: width 0.1s ease;
  z-index: 9999;
}

JavaScript:

const updateProgressBar = () => {
  const scrolled = window.pageYOffset;
  const height = document.documentElement.scrollHeight - window.innerHeight;
  const progress = (scrolled / height) * 100;
  document.querySelector('.progress-bar')
    .style.setProperty('--scroll-progress', ${progress}%);
};

window.addEventListener('scroll', () => { requestAnimationFrame(updateProgressBar); });

Performance Best Practices

  • Use will-change sparingly - Only on actively animating elements
  • Prefer transforms over position - GPU-accelerated
  • Use requestAnimationFrame - Sync with browser refresh rate
  • Disconnect observers - Remove observers for one-time animations
  • Lazy load images - Don't animate before images are loaded
  • Optimization example:

    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          entry.target.classList.add('visible');
          observer.unobserve(entry.target);
        }
      });
    }, { threshold: 0.1 });
    

    Respecting User Preferences

    Always respect prefers-reduced-motion:

    @media (prefers-reduced-motion: reduce) {
      .fade-in,
      .slide-left,
      .slide-right,
      .scale-rotate {
        transition: none;
        opacity: 1;
        transform: none;
      }
    }
    

    Conclusion

    Scroll animations enhance storytelling and user engagement when used thoughtfully. Start with simple fade-ins, then experiment with more complex effects. Always prioritize performance and accessibility over flashy animations.

    Related posts

    Continue reading on nearby topics.

    Micro-Interactions That Feel InstantShip subtle CSS micro-interactions for buttons, cards, and forms that improve feedback without hurting performance.Motion Budget Strategy for 60fps Product InterfacesPlan animation cost like a budget: prioritize meaningful transitions, cut decorative overload, and keep UI responsive on real devices.Skeleton Loader Patterns That Feel Fast and PolishedDesign skeleton loading states that match final layout and reduce perceived latency without harming accessibility.View Transitions API in Real Products: Smooth Navigation Without SPA JankLearn when to use View Transitions API, how to keep transitions fast, and how to avoid UX and SEO regressions in modern frontend apps.

    Comments

    0

    Sign in to leave a comment.

    No comments yet. Be the first.