Scroll Reveal Patterns
Scroll reveal là animation trigger khi element đi vào viewport. Làm đúng: tạo cảm giác trang “sống”, content xuất hiện đúng lúc người đọc nhìn đến. Làm sai: người dùng scroll xuống và thấy… trống, phải chờ animation, mất nội dung.
Scroll bên trong ô để xem từng item fade-up vào
Intersection Observer
Nền tảng của scroll reveal là IntersectionObserver — native API theo dõi khi element giao cắt với viewport.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
entry.target.classList.add('revealed');
observer.unobserve(entry.target); // chỉ animate một lần
});
},
{ threshold: 0.15 } // trigger khi 15% element visible
);
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.reveal.revealed {
opacity: 1;
transform: translateY(0);
}
Tại sao unobserve sau khi trigger: scroll reveal nên chỉ chạy một lần. Nếu người dùng scroll lên xuống nhiều lần mà animation reset, gây rối và không tự nhiên.
Pattern 1: Simple fade-up
Phù hợp cho hầu hết content block (heading, paragraph, image).
.fade-up {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.fade-up.revealed {
opacity: 1;
transform: translateY(0);
}
Translate chỉ cần 16–24px để tạo directional cue. Không cần 60px hay 100px — sẽ trông như element “rơi từ trên trời”.
Pattern 2: Stagger group
Nhiều element trong một container xuất hiện lần lượt. Xem chi tiết trong Stagger Animation.
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const items = entry.target.querySelectorAll('.stagger-item');
items.forEach((el, i) => {
el.style.transitionDelay = `${i * 60}ms`;
el.classList.add('revealed');
});
observer.unobserve(entry.target);
});
}, { threshold: 0.1 });
Observe container, không phải từng item: nếu observe từng item riêng lẻ, các item không visible sẽ trigger ngẫu nhiên khi scroll. Container là đơn vị semantic.
Pattern 3: Scroll-linked (parallax)
Element di chuyển với tốc độ khác nhau theo scroll position. Khác với reveal — đây là continuous animation.
// Scroll-linked với CSS custom property
const section = document.querySelector('.parallax-section');
const img = section.querySelector('.parallax-img');
window.addEventListener('scroll', () => {
const rect = section.getBoundingClientRect();
const progress = 1 - (rect.bottom / (rect.height + window.innerHeight));
img.style.setProperty('--parallax-y', `${progress * 40}px`);
}, { passive: true });
.parallax-img {
transform: translateY(var(--parallax-y, 0));
will-change: transform;
}
Dùng { passive: true } cho scroll listener để không block rendering. Và will-change: transform để GPU composite layer.
Dùng tiết kiệm: parallax đẹp nhưng tốn performance. Trên mobile, cân nhắc disable hoàn toàn.
Threshold và rootMargin
threshold là tỉ lệ element phải visible để trigger (0 = 1 pixel, 1 = toàn bộ element).
rootMargin mở rộng hoặc thu hẹp vùng trigger (tính từ edge của viewport).
// Trigger sớm hơn 100px trước khi element vào viewport
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px -100px 0px', // bottom margin âm
threshold: 0
});
rootMargin: '0px 0px -100px 0px' nghĩa là trigger khi element còn cách bottom viewport 100px — đủ thời gian để animation bắt đầu trước khi người dùng thực sự nhìn đến.
Sai lầm phổ biến
Above-the-fold content bị ẩn: content nhìn thấy ngay khi load không được reveal ngay — người dùng thấy trang trắng rồi animation mới chạy. Kiểm tra: tất cả content above fold phải visible ngay lập tức.
// Giải pháp: observe chỉ elements below fold
const foldLine = window.innerHeight;
document.querySelectorAll('.reveal').forEach(el => {
if (el.getBoundingClientRect().top < foldLine) {
el.classList.add('revealed'); // show immediately
} else {
observer.observe(el);
}
});
Quá nhiều element reveal cùng lúc: nếu 20 element đều trigger ở cùng threshold, chúng xuất hiện gần như đồng thời — không có cascade. Tăng threshold hoặc dùng stagger.
Không handle prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
}
Tham khảo: Stagger Animation, Cubic Bezier chuyên sâu.