← Tất cả bài viết
Cuộn mượt trong Next.js với GSAP & Lenis
Hướng dẫn thực chiến để có hiệu ứng cuộn mượt 60fps trong Next.js bằng Lenis và GSAP ScrollTrigger — cách cài đặt, animation theo scroll và các lỗi hiệu năng thường gặp.

Cuộn mượt (smooth scroll) là một trong những chi tiết phân biệt một template tầm thường với một website được chăm chút. Làm tốt, nó khiến animation theo scroll có cảm giác nặng và có chủ đích. Làm dở, nó "đánh nhau" với trình duyệt, rớt khung hình và phá vỡ khả năng tiếp cận. Bài này hướng dẫn cách thêm cuộn mượt cho Next.js đúng kiểu các site đoạt giải thưởng — dùng Lenis cho phần cuộn và GSAP ScrollTrigger cho animation — kèm cả những lỗi ít ai nhắc tới.
Chúng ta nhắm tới App Router (Next.js 14+), giữ 60fps và tôn trọng
prefers-reduced-motion.
Vì sao chọn Lenis thay vì CSS hay thư viện scroll khác
Bạn có ba lựa chọn chính cho cuộn mượt, và chúng không ngang nhau.
| Cách làm | Cảm giác | Hiệu năng | Kiểm soát |
|---|---|---|---|
scroll-behavior: smooth (CSS) | Chỉ mượt khi nhảy tới anchor, không mượt khi lăn chuột | Tốt | Không |
| Thư viện "smooth scrollbar" nặng | Mượt nhưng hay giật, chiếm quyền layout | Hên xui | Trung bình |
| Lenis | Mượt theo quán tính tự nhiên cho cả chuột lẫn cảm ứng | Xuất sắc | Toàn quyền |
Lenis được cộng đồng creative-dev ưa chuộng vì nó chuẩn hoá thao tác cuộn (lăn chuột, trackpad, cảm ứng) thành một giá trị đã được làm mượt, mà không giả lập thanh cuộn hay phá vỡ cuộn gốc của trình duyệt. Nó cũng cung cấp một giá trị cập nhật mỗi khung hình để bạn đưa thẳng vào GSAP — đúng thứ bạn cần cho animation theo scroll.
1. Cài đặt
npm install lenis gsap
2. Provider Lenis cho App Router
Cuộn mượt là việc của phía client, nên hãy bọc ứng dụng trong một client component nhỏ. Mẹo quan trọng để tối ưu hiệu năng: chạy Lenis và GSAP từ chung một vòng lặp requestAnimationFrame để chúng dùng chung một mốc thời gian và không "đánh nhau".
// app/lenis-provider.tsx
'use client';
import { ReactLenis, type LenisRef } from 'lenis/react';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function LenisProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<LenisRef>(null);
useEffect(() => {
function raf(time: number) {
lenisRef.current?.lenis?.raf(time);
ScrollTrigger.update();
}
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => gsap.ticker.remove(raf);
}, []);
return (
<ReactLenis root options={{ autoRaf: false }} ref={lenisRef}>
{children}
</ReactLenis>
);
}
Sau đó gắn nó vào root layout, bên trong <body>:
// app/layout.tsx
import { LenisProvider } from './lenis-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body>
<LenisProvider>{children}</LenisProvider>
</body>
</html>
);
}
Vậy là xong — giờ bạn đã có cuộn mượt theo quán tính trên toàn site.
3. Animation theo scroll với ScrollTrigger
Vì Lenis cập nhật ScrollTrigger mỗi khung hình, animation của bạn đồng bộ hoàn hảo với scroll đã được làm mượt. Một section được ghim (pin) và scrub trông như sau:
'use client';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
export function Reveal() {
const ref = useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap.to('.panel', {
yPercent: -100,
ease: 'none',
scrollTrigger: {
trigger: ref.current,
start: 'top top',
end: '+=150%',
scrub: true,
pin: true,
},
});
}, { scope: ref });
return <div ref={ref}>{/* các .panel con */}</div>;
}
scrub: true gắn tiến trình animation trực tiếp vào vị trí scroll, nên giá trị đã làm mượt của
Lenis được đưa thẳng vào chuyển động. Đây cũng chính là kỹ thuật phía sau phần showcase được ghim
trên hầu hết các trang case study.
4. Các lỗi hiệu năng cần tránh
Đây là chỗ phần lớn triển khai bị "vỡ". Ghi nhớ:
- Chỉ animate
transformvàopacity. Chúng chạy trên compositor và không kích hoạt layout. Animatetop,widthhaymargintheo scroll sẽ làm rớt khung hình. - Một vòng lặp RAF duy nhất. Nếu Lenis tự chạy
requestAnimationFramevà GSAP chạy ticker riêng, bạn có hai vòng lặp và bị giật nhẹ. Provider ở trên tắtautoRafcủa Lenis và để GSAP ticker điều khiển cả hai. - Gọi
ScrollTrigger.refresh()sau khi layout thay đổi (font tải xong, đổi route, ảnh ổn định) để vị trí trigger luôn chính xác. - Lazy-load phần dưới màn hình. Cuộn mượt khiến trang dài trở nên hấp dẫn; giữ Core Web Vitals khoẻ mạnh bằng cách trì hoãn các component nặng.
5. Khả năng tiếp cận: tôn trọng reduced motion
Cuộn mượt và animation theo scroll lớn có thể gây chóng mặt. Luôn tôn trọng tuỳ chọn của người
dùng — tắt easing và animation khi prefers-reduced-motion: reduce được bật:
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ví dụ: truyền `options={{ smoothWheel: !reduced }}` cho Lenis,
// và bọc animation ScrollTrigger trong `if (!reduced) { ... }`.
gsap.matchMedia() giúp việc này gọn gàng — đăng ký biến thể reduced-motion và GSAP tự đổi qua
lại.
Câu hỏi thường gặp
Lenis có hại cho SEO hay Core Web Vitals không?
Không. Lenis làm mượt phần cuộn thị giác mà không chặn main thread nếu được cài với một vòng
lặp RAF duy nhất. Nội dung vẫn là HTML render từ server, nên việc crawl và index không bị ảnh
hưởng. Giữ animation ở transform/opacity thì CLS/INP vẫn khoẻ.
Có chạy với App Router và SSR của Next.js không?
Có. Provider là client component, nên nó hydrate ở client trong khi các trang vẫn được render từ server. Chỉ cần gắn một lần ở root layout.
Làm sao để cuộn tới một section?
Dùng lenis.scrollTo('#section') (hoặc một offset số). Lenis chặn cú nhảy và ease tới đích, nên
điều hướng trong trang khớp với phần còn lại của trải nghiệm.
Cuộn mượt chỉ là một lượng code nhỏ nhưng tác động lớn tới cảm nhận chất lượng. Kết hợp Lenis với GSAP, giữ mọi thứ trên compositor, chạy từ một vòng lặp duy nhất và tôn trọng reduced motion — bạn sẽ có được cảm giác nặng, có chủ đích khiến người dùng ở lại.
Muốn tích hợp điều này vào sản phẩm của bạn? Xem cách tôi làm việc với các đội nhóm.