Skip to main content
CSS 6 mins read Devs2.org

CSS Scroll-driven Animations: Tạo Hiệu Ứng Cuộn Trang Không Cần JavaScript

Khám phá CSS Scroll-driven Animations — tính năng CSS bản địa cho phép tạo hiệu ứng animation dựa trên vị trí cuộn trang mà không cần một dòng JavaScript nào.

#css #scroll-driven-animations #modern-css #web-development #animation

Minh họa CSS Scroll-driven Animations

Giới thiệu

Hãy tưởng tượng bạn đang lướt qua một trang web, và khi bạn cuộn xuống, mỗi phần tử dần xuất hiện — mờ đi rồi rõ lên, trượt từ bên trái hoặc phải, xoay nhẹ theo hướng cuộn. Trước đây, mọi thứ này đều cần JavaScript — thường là kết hợp giữa IntersectionObserver, requestAnimationFrame và hàng tá dòng code.

Nhưng giờ đây, CSS Scroll-driven Animations đã thay đổi一切.

Bạn không còn cần thêm event listener, không cần tính toán vị trí cuộn thủ công, không cần thư viện bên ngoài. Tất cả chỉ bằng CSS thuần túy.

Trong bài viết này, chúng ta sẽ cùng khám phá toàn bộ về scroll-driven animations: từ khái niệm cơ bản đến các kỹ thuật nâng cao, kèm ví dụ thực tế mà bạn có thể áp dụng ngay.

Scroll-driven Animations là gì?

Scroll-driven Animations (còn gọi là Scroll-linked Animations) là một nhóm các thuộc tính CSS mới cho phép bạn gắn kết animation với tiến độ cuộn trang của người dùng. Thay vì animation chạy theo thời gian (animation-duration: 2s), animation chạy theo vị trí scroll (scroll-timeline).

Tại sao điều này quan trọng?

Với phương pháp truyền thống (JavaScript), bạn phải:

// Cách cũ — cần JavaScript
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    }
  });
});

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

window.addEventListener('scroll', () => {
  // Tính toán position, trigger animation...
});

Với Scroll-driven Animations, mọi thứ trở nên đơn giản:

/* Cách mới — chỉ CSS */
.fade-in {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: cover 0% cover 30%;
}

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

Chỉ bốn dòng CSS, không cần một dòng JavaScript nào!

Lợi ích chính

AspectJavaScript ApproachScroll-driven Animations
Code complexityNhiều dòng JS + CSSChỉ CSS
PerformanceCó thể gây jank nếu không optimizeThuộc thread của compositor
MaintainabilityLogic JS phân mảnhDeclarative, dễ đọc
Progressive enhancementKhó fallbackTự động graceful degrade

Các thành phần cốt lõi

Scroll-driven Animations gồm hai phần chính: Timeline (xác định tiến độ) và Animation (hiệu ứng).

1. animation-timeline: Xác định nguồn scroll

Thuộc tính animation-timeline nói cho trình duyệt biết animation nên phụ thuộc vào đâu:

view() — Theo khả năng hiển thị của phần tử

.card {
  animation: fadeInUp linear;
  animation-timeline: view();
  animation-range: contain 0% contain 60%;
}

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

Thuộc tính animation-range xác định khi nào animation bắt đầu và kết thúc dựa trên mức độ hiển thị của element:

  • cover X% Y%: Animation chạy khi element được che từ X% đến Y%
  • contain X% Y%: Animation chạy khi element nằm hoàn toàn trong viewport từ X% đến Y%
  • entry(X%) Y%: Dựa trên vị trí entry của element
  • viewport(X%) Y%: Dựa trên vị trí của viewport

scroll() — Theo thanh cuộn tổng thể

.sticky-header {
  animation: shrink linear;
  animation-timeline: scroll(root);
}

@keyframes shrink {
  from {
    padding: 2rem 4rem;
    font-size: 2rem;
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(10px);
  }
  to {
    padding: 0.75rem 2rem;
    font-size: 1.25rem;
    background: rgba(255, 255, 255, 0.98);
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  }
}

scroll(root) tham chiếu đến thanh cuộn của toàn trang. Bạn cũng có thể dùng scroll(self) để gắn với scrollbar của chính element đó:

.scrollable-panel {
  overflow-y: auto;
  height: 400px;

  .progress-bar {
    animation: fill linear;
    animation-timeline: scroll(self);
  }

  @keyframes fill {
    from { width: 0%; }
    to   { width: 100%; }
  }
}

Timeline tùy chỉnh với scroll(#element)

Bạn có thể tạo timeline từ bất kỳ element nào:

.section-marker {
  animation-width: 500px; /* Chiều dài của timeline */
}

.content {
  animation: highlight linear;
  animation-timeline: section-marker;
}

2. animation-range: Xác định khoảng thời gian

animation-range là chìa khóa để tinh chỉnh khi nào animation diễn ra:

/* Animation chạy khi element bắt đầu đi vào viewport */
.reveal-top {
  animation: slideDown linear;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

/* Animation chạy khi element hoàn toàn trong viewport */
.highlight-active {
  animation: glow linear;
  animation-timeline: view();
  animation-range: contain 10% contain 90%;
}

/* Animation chạy xuyên suốt quá trình xuất hiện */
.full-reveal {
  animation: fadeScale linear;
  animation-timeline: view();
  animation-range: cover 0% contain 100%;
}

3. animation-play-state: Tạm dừng/Khởi động lại

.element {
  animation: myAnimation linear;
  animation-timeline: view();
  animation-play-state: paused; /* Bắt buộc phải set paused! */
}

Quan trọng: Luôn đặt animation-play-state: paused mặc định, để animation chỉ chạy khi phần tử đi vào viewport. Nếu không bỏ qua bước này, animation sẽ chạy liên tục.

Ví dụ thực tế 1: Hero Section Parallax

Tạo hiệu ứng parallax đẹp mắt cho hero section:

<section class="hero">
  <div class="hero-bg"></div>
  <div class="hero-content">
    <h1>CSS Scroll-driven Animations</h1>
    <p>Animation mạnh mẽ, không cần JavaScript</p>
    <a href="#learn-more" class="cta-button">Khám phá</a>
  </div>
</section>
.hero {
  position: relative;
  height: 100vh;
  overflow: hidden;
}

.hero-bg {
  position: absolute;
  inset: -20%;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  z-index: 0;

  /* Di chuyển chậm hơn tốc độ cuộn → hiệu ứng parallax */
  animation: parallaxMove linear;
  animation-timeline: scroll(root);
  animation-range: cover 0% cover 150%;
}

@keyframes parallaxMove {
  from {
    transform: translateY(0) scale(1);
  }
  to {
    transform: translateY(-20%) scale(1.1);
  }
}

.hero-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  text-align: center;
  color: white;
  padding: 2rem;

  h1 {
    animation: heroFadeIn linear;
    animation-timeline: view();
    animation-range: cover 0% cover 40%;
    font-size: clamp(2rem, 5vw, 4rem);
    font-weight: 800;
    margin-bottom: 1rem;
  }

  p {
    animation: heroFadeIn linear;
    animation-timeline: view();
    animation-range: cover 10% cover 50%;
    font-size: clamp(1rem, 2vw, 1.5rem);
    opacity: 0.9;
    margin-bottom: 2rem;
  }

  .cta-button {
    animation: heroFadeIn linear;
    animation-timeline: view();
    animation-range: cover 20% cover 60%;
    padding: 1rem 2.5rem;
    background: white;
    color: #764ba2;
    border-radius: 9999px;
    font-weight: 700;
    text-decoration: none;
    transition: transform 0.2s, box-shadow 0.2s;

    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
    }
  }
}

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

Ví dụ thực tế 2: Scroll-triggered Content Reveal

Tạo hiệu ứng từng khối nội dung xuất hiện khi cuộn:

<main>
  <article class="blog-post">
    <section class="content-block">
      <h2>Phần 1: Giới thiệu</h2>
      <p>Nội dung giới thiệu...</p>
    </section>

    <section class="content-block">
      <h2>Phần 2: Cài đặt</h2>
      <p>Hướng dẫn cài đặt...</p>
    </section>

    <section class="content-block">
      <h2>Phần 3: Ví dụ</h2>
      <p>Code examples...</p>
    </section>
  </article>
</main>
.content-block {
  padding: 3rem 2rem;
  margin-bottom: 2rem;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);

  &.from-left {
    animation: slideFromLeft linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }

  &.from-right {
    animation: slideFromRight linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }

  &.scale-up {
    animation: scaleUp linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 25%;
  }

  &.rotate-in {
    animation: rotateIn linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 25%;
  }
}

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

@keyframes slideFromRight {
  from {
    opacity: 0;
    transform: translateX(100px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes scaleUp {
  from {
    opacity: 0;
    transform: scale(0.85);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes rotateIn {
  from {
    opacity: 0;
    transform: perspective(600px) rotateY(-15deg);
  }
  to {
    opacity: 1;
    transform: perspective(600px) rotateY(0);
  }
}

Phân phối xen kẽ các direction

<div class="timeline">
  <div class="event left">2024 — Ra mắt Chrome 115</div>
  <div class="event right">2024 — Hỗ trợ Firefox dev</div>
  <div class="event left">2025 — Phổ cập rộng rãi</div>
  <div class="event right">2025 — Hỗ trợ Safari</div>
</div>
.timeline {
  max-width: 800px;
  margin: 0 auto;
  position: relative;

  &::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 0;
    bottom: 0;
    width: 3px;
    background: linear-gradient(to bottom, #3b82f6, #8b5cf6);
    transform: translateX(-50%);
  }
}

.event {
  padding: 1.5rem 2rem;
  margin: 2rem 0;
  background: white;
  border-radius: 0.75rem;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
  position: relative;
  width: 45%;
  font-size: 1.1rem;

  &.left {
    margin-right: auto;
    text-align: right;
    animation: slideFromLeft linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }

  &.right {
    margin-left: auto;
    text-align: left;
    animation: slideFromRight linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }

  /* Dot marker trên timeline */
  &::after {
    content: '';
    position: absolute;
    top: 1.5rem;
    width: 16px;
    height: 16px;
    background: #8b5cf6;
    border-radius: 50%;
    border: 3px solid white;
    box-shadow: 0 0 0 3px #8b5cf6;
  }

  &.left::after {
    right: -52px;
  }

  &.right::after {
    left: -52px;
  }
}

Ví dụ thực tế 3: Progress Indicator

Thanh tiến trình thay đổi theo vị trí cuộn trang:

<div class="progress-container">
  <div class="progress-fill"></div>
</div>

<article class="long-article">
  <!-- Nội dung dài ở đây -->
  <section>
    <h2>Chương 1: Nền tảng</h2>
    <p>Tôi tìm hiểu về CSS cơ bản...</p>
  </section>
  <section>
    <h2>Chương 2: Layout</h2>
    <p>Sau đó học về Flexbox và Grid...</p>
  </section>
  <!-- ... nhiều chương hơn -->
</article>
.progress-container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: #e5e7eb;
  z-index: 100;
  border-radius: 0 0 2px 2px;

  .progress-fill {
    height: 100%;
    width: 0%;
    background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
    border-radius: 2px;

    /* Animation driven bởi scroll root */
    animation: progressWidth linear;
    animation-timeline: scroll(root);
    animation-range: cover 0% cover 100%;
  }
}

@keyframes progressWidth {
  from { width: 0%; }
  to   { width: 100%; }
}

Progress indicator cho từng section

.section-indicator {
  position: fixed;
  right: 1.5rem;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 1rem;
  z-index: 100;

  .dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: #d1d5db;
    transition: background 0.3s, transform 0.3s;

    /* Mỗi dot sáng lên tương ứng với section của nó */
    &.active {
      background: #3b82f6;
      transform: scale(1.3);
    }
  }
}

/* Mỗi section đánh dấu timeline riêng */
#chapter-1 { scroll-timeline: --chapter1; }
#chapter-2 { scroll-timeline: --chapter2; }
#chapter-3 { scroll-timeline: --chapter3; }

/* Dot tương ứng */
.dot--ch1 {
  animation: dotActive linear;
  animation-timeline: --chapter1;
}

@keyframes dotActive {
  from {
    background: #3b82f6;
    transform: scale(1.3);
  }
  to {
    background: #d1d5db;
    transform: scale(1);
  }
}

Ví dụ thực tế 4: Text Scramble / Color Shift Effect

Hiệu ứng đổi màu chữ khi cuộn qua:

.gradient-text {
  background: linear-gradient(
    90deg,
    #ef4444,
    #f97316,
    #eab308,
    #22c55e,
    #3b82f6,
    #8b5cf6,
    #ec4899
  );
  background-size: 200% auto;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;

  animation: colorShift 1 linear;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes colorShift {
  from {
    background-position: 0% center;
  }
  to {
    background-position: 200% center;
  }
}

Ứng dụng vào header sticky:

.navbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  padding: 1.5rem 3rem;
  background: transparent;
  z-index: 100;
  transition: padding 0.3s;

  /* Thu nhỏ khi cuộn */
  animation: navbarShrink linear;
  animation-timeline: scroll(root);
  animation-range: cover 0% cover 30%;
}

@keyframes navbarShrink {
  from {
    padding: 1.5rem 3rem;
    background: transparent;
    backdrop-filter: none;
  }
  to {
    padding: 0.75rem 3rem;
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(12px);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
}

Kết hợp scroll-driven với hover effects cho gallery:

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 1.5rem;
  padding: 2rem;
}

.gallery-item {
  position: relative;
  border-radius: 1rem;
  overflow: hidden;
  aspect-ratio: 4/3;

  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 0.5s;
  }

  /* Zoom effect khi vào viewport */
  animation: zoomReveal linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 50%;

  /* Overlay caption */
  .caption {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 2rem 1.5rem 1.5rem;
    background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
    color: white;
    transform: translateY(100%);
    transition: transform 0.3s;

    .caption-title {
      font-weight: 700;
      font-size: 1.1rem;
      margin-bottom: 0.25rem;
    }

    .caption-subtitle {
      font-size: 0.875rem;
      opacity: 0.8;
    }
  }

  &:hover {
    img {
      transform: scale(1.05);
    }

    .caption {
      transform: translateY(0);
    }
  }
}

@keyframes zoomReveal {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

Tương tác với Prefers-reduced-motion

Luôn cung cấp fallback cho người dùng nhạy cảm với chuyển động:

@media (prefers-reduced-motion: reduce) {
  .animated-element {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  .gradient-text {
    background: none;
    -webkit-text-fill-color: #7c3aed;
    background: #7c3aed;
  }
}

Hoặc sử dụng @container query kết hợp với media query để responsive-friendly:

/* Chỉ bật animation khi người dùng không yêu cầu reduced motion */
.animated-element {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: cover 0% cover 20%;
}

@media (prefers-reduced-motion: no-preference) {
  .animated-element {
    animation: reveal linear;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .animated-element {
    opacity: 1;
    transform: none;
    animation: none;
  }
}

Browser Support & Progressive Enhancement

Hiện tại, scroll-driven animations được hỗ trợ trong:

Trình duyệtVersionStatus
Chrome115+✅ Full support
Edge115+✅ Full support
FirefoxDev Edition🧪 Experimental (flag)
Safari🔜 Planned
Android Chrome115+✅ Full support

Để đảm bảo trải nghiệm tốt cho tất cả người dùng, hãy sử dụng Feature Query (@supports):

/* Fallback cho browsers không hỗ trợ */
.card {
  opacity: 1;
  transform: none;
}

/* Chỉ áp dụng animation khi browser hỗ trợ */
@supports (animation-timeline: view()) {
  .card {
    animation: cardReveal linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 20%;
  }

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

Đây chính là sức mạnh của progressive enhancement: người dùng trên browser hiện đại sẽ có animation đẹp, còn người dùng trên browser cũ vẫn xem được nội dung bình thường — chỉ thiếu hiệu ứng, không thiếu nội dung.

Best Practices

1. Đừng lạm dụng animation

/* ❌ Tránh: quá nhiều animation chồng chéo */
.heavy-section {
  animation: fade linear;
  animation-timeline: view();

  .child-1 {
    animation: slide linear;
    animation-timeline: view();
  }

  .child-2 {
    animation: scale linear;
    animation-timeline: view();
  }
}

/* ✅ Tốt hơn: tập trung vào điểm nhấn chính */
.key-section {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: cover 0% cover 15%;
}

2. Sử dụng both để giữ trạng thái cuối

/* ✅ Luôn thêm 'both' để giữ keyframe cuối cùng */
.element {
  animation: reveal linear both;
  animation-timeline: view();
}

Nếu không có both, phần tử sẽ quay lại trạng thái ban đầu sau khi rời viewport.

3. Kết hợp với will-change judiciously

.element {
  animation: slide linear both;
  animation-timeline: view();
  will-change: transform, opacity; /* Tối ưu cho compositor */
}

Lưu ý: Chỉ dùng will-change khi thực sự cần.过度 sử dụng có thể gây tốn bộ nhớ.

4. Test trên mobile

Scroll-driven animations hoạt động khác nhau trên mobile do cách xử lý touch scrolling. Luôn test trên thiết bị thật:

/* Mobile optimization */
@media (max-width: 768px) {
  .reveal-element {
    animation-range: cover 0% cover 30%; /* Mở rộng range cho dễ nhìn */
  }
}

5. Giảm số lượng animation đồng thời

Mỗi animation tạo workload cho CPU/GPU. Trên trang có nhiều section:

/* stagger các animation */
.block:nth-child(1) {
  animation-range: cover 0% cover 20%;
}
.block:nth-child(2) {
  animation-range: cover 5% cover 25%;
}
.block:nth-child(3) {
  animation-range: cover 10% cover 30%;
}
.block:nth-child(4) {
  animation-range: cover 15% cover 35%;
}

So sánh với các giải pháp trước đây

FeatureIntersectionObserver + JSScroll-driven AnimationsGSAP ScrollTrigger
Code requiredNhiều JS + CSSChỉ CSSPlugin JS
Bundle size~0 (native)0 bytes~20 KB minified
PerformanceGood (nếu optimize)Excellent (compositor thread)Excellent
ComplexityMediumLowMedium-High
Fine-grained controlHighMediumVery High
Native browser supportUniversal (ES6+)Chrome 115+Library-dependent
AccessibilityManualNative (reduced-motion)Manual
Fallback qualityBreaks logicOnly missing animationOnly missing animation

Kết luận

CSS Scroll-driven Animations đại diện cho một bước tiến lớn trong việc đưa animation trở về với CSS — ngôn ngữ vốn dĩ dành cho styling và visual effect. Bằng cách loại bỏ dependency vào JavaScript, chúng ta được lợi ích về:

  • Performance tốt hơn nhờ chạy trên compositor thread
  • Code sạch hơn — declarative thay vì imperative
  • Bảo trì dễ dàng — không cần sync state giữa JS và CSS
  • Progressive enhancement tự nhiên — không JavaScript required

Hãy bắt đầu thử nghiệm ngay hôm nay! Ngay cả khi bạn chỉ target Chrome 115+, bạn đã có thể mang lại trải nghiệm tuyệt vời cho phần lớn người dùng. Và với @supports query, những người dùng trên browser cũ vẫn sẽ có trải nghiệm nội dung đầy đủ.

💡 Mẹo: Mở Chrome DevTools, kích hoạt flag #enable-experimental-web-platform-features (nếu cần), và bắt đầu playground tại codepen.io để thử nghiệm scroll-driven animations trước khi áp dụng vào dự án thực tế.

Happy scrolling! 🚀

Recently Used Tools