Scroll-Triggered Animations with CSS and Intersection Observer
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
will-change sparingly - Only on actively animating elementsrequestAnimationFrame - Sync with browser refresh rateOptimization 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.

Comments
0Sign in to leave a comment.