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 trong ô này
01
02
03
04
05

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.