Cuộn mượt toàn trang
Lenis chạy theo nhịp GSAP: cả trang cuộn mượt, ScrollTrigger vẫn khớp nhịp. Có nút tắt để so sánh.
Cách hoạt động
- 1.Web nhìn "cứng" phần lớn là do cuộn native: mỗi nấc lăn chuột nhảy một đoạn. Lenis làm trang trôi tới vị trí mới thay vì nhảy.
- 2.Ba dòng quan trọng nhất: autoRaf: false, gọi lenis.raf trong gsap.ticker, và lenis.on("scroll", ScrollTrigger.update). Lenis và mọi hiệu ứng dùng chung một vị trí cuộn trong cùng một frame.
- 3.gsap.ticker.lagSmoothing(0) để khi máy chậm, cuộn không bị nhảy cóc.
- 4.Link neo lướt tới mục bằng lenis.scrollTo, rồi chuyển focus để người dùng bàn phím không bị lạc.
Thử chỉnh
- Trôi lâu hơn hoặc ngắn hơn
- Sửa lerp: nhỏ hơn (0.06) thì trôi lâu hơn, lớn hơn (0.15) thì dừng nhanh hơn.
- Khối có thanh cuộn riêng (popup, menu) không cuộn được
- Thêm data-lenis-prevent vào khối đó.
- Menu dính đầu trang che mất tiêu đề
- Sửa scroll-margin-top trong CSS (đang là 72px) bằng chiều cao menu. Lenis tự tính theo giá trị này.
Code tham khảo
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cuộn mượt toàn trang · bodev.vn</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;700;800&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #f3efe7;
--ink: #1c1b19;
--muted: #7a746a;
--accent: #e0532e;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
}
/* Lenis cần mấy dòng này (copy nguyên văn). */
html.lenis,
html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }
.topbar {
position: fixed;
top: 12px;
left: 50%;
translate: -50% 0;
z-index: 10;
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
border-radius: 999px;
background: rgba(28, 27, 25, 0.9);
backdrop-filter: blur(8px);
}
.topbar a,
.toggle {
padding: 8px 14px;
border-radius: 999px;
color: #f3efe7;
font: inherit;
font-size: 14px;
text-decoration: none;
white-space: nowrap;
}
.topbar a:hover { background: rgba(255, 255, 255, 0.12); }
.toggle { border: 0; background: var(--accent); cursor: pointer; }
.toggle[aria-pressed="false"] { background: #5b5750; }
section { padding: 0 clamp(20px, 6vw, 80px); scroll-margin-top: 72px; }
.hero { min-height: 90vh; display: flex; flex-direction: column; justify-content: center; }
.kicker { margin: 0; color: var(--accent); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.hero h1 { margin: 10px 0 0; font-size: clamp(44px, 8vw, 104px); font-weight: 800; line-height: 0.95; }
.lead { max-width: 36ch; margin: 18px 0 0; color: var(--muted); font-size: 18px; }
.parallax { min-height: 140vh; padding-top: 12vh; }
.parallax h2 { margin: 0; font-size: clamp(28px, 4vw, 48px); }
.blocks { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3vw; margin-top: 18vh; }
.block {
display: grid;
place-items: center;
aspect-ratio: 3 / 4;
border-radius: 18px;
background: #d9d2c4;
font-size: clamp(20px, 3vw, 36px);
font-weight: 800;
will-change: transform;
}
.block.accent { background: var(--accent); color: #fff; }
.block.dark { background: var(--ink); color: var(--bg); }
.fill { min-height: 120vh; display: flex; align-items: center; }
.fill-text { position: relative; margin: 0; font-size: clamp(40px, 7vw, 96px); font-weight: 800; line-height: 1.05; }
.fill-base { color: rgba(28, 27, 25, 0.15); }
/* Lớp chữ màu phủ lên, JS mở dần bằng clip-path. */
.fill-top { position: absolute; inset: 0; color: var(--ink); clip-path: inset(0 100% 0 0); }
.outro { min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
.outro h2 { max-width: 18ch; margin: 0 0 24px; font-size: clamp(28px, 4vw, 48px); }
.btn {
/* CTA trong demo là <button>, không gắn link. Nối vào form hoặc popup của bạn. */
border: 0;
font: inherit;
cursor: pointer;
padding: 14px 26px;
border-radius: 999px;
background: var(--ink);
color: #fff;
font-weight: 700;
}
@media (max-width: 760px) {
.topbar a:not(:first-child) { display: none; }
.blocks { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<nav class="topbar" aria-label="Mục lục">
<a href="#mo-dau">Mở đầu</a>
<a href="#parallax">Parallax</a>
<a href="#chu">Chữ tô màu</a>
<button type="button" class="toggle" aria-pressed="true">Cuộn mượt: <b>Bật</b></button>
</nav>
<section id="mo-dau" class="hero">
<p class="kicker">Cơ bản · Dùng cho mọi trang</p>
<h1>Cuộn mượt cả trang</h1>
<p class="lead">Lăn chuột thử, rồi bấm nút góc trên để tắt đi và so sánh.</p>
</section>
<section id="parallax" class="parallax">
<h2>Các khối trôi với tốc độ khác nhau</h2>
<!-- data-speed: 1 = trôi như trang, lớn hơn 1 = trôi nhanh hơn, nhỏ hơn 1 = chậm hơn -->
<div class="blocks">
<div class="block" data-speed="0.7">0.7×</div>
<div class="block accent" data-speed="1.3">1.3×</div>
<div class="block" data-speed="0.9">0.9×</div>
<div class="block dark" data-speed="1.5">1.5×</div>
</div>
</section>
<section id="chu" class="fill">
<p class="fill-text" aria-label="Cuộn tới đâu, chữ tô màu tới đó.">
<span class="fill-base" aria-hidden="true">Cuộn tới đâu, chữ tô màu tới đó.</span>
<span class="fill-top" aria-hidden="true">Cuộn tới đâu, chữ tô màu tới đó.</span>
</p>
</section>
<section class="outro">
<h2>Web nhìn "cứng" phần lớn là do cuộn.</h2>
<button type="button" class="btn">Xem thêm hiệu ứng</button>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script>
// Cuộn mượt toàn trang — bodev.vn/lab/cuon-muot-lenis
// Cần: gsap + ScrollTrigger + Lenis.
gsap.registerPlugin(ScrollTrigger);
// ---------- 1. Lenis chạy theo nhịp của GSAP ----------
// Quan trọng nhất là 3 dòng: tắt autoRaf, gọi lenis.raf trong gsap.ticker, và báo ScrollTrigger mỗi khi cuộn.
// Như vậy Lenis và mọi hiệu ứng ScrollTrigger dùng chung một vị trí cuộn trong cùng một frame, không lệch nhịp.
let lenis = null;
const raf = (time) => lenis.raf(time * 1000); // ticker tính bằng giây, Lenis cần mili giây
function startSmooth() {
lenis = new Lenis({ autoRaf: false, lerp: 0.1 }); // lerp nhỏ = trôi lâu hơn
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0); // không "nhảy cóc" khi tab bị chậm, cuộn không bị giật
}
function stopSmooth() {
gsap.ticker.remove(raf);
gsap.ticker.lagSmoothing(500, 33); // trả về mặc định của GSAP
lenis.destroy();
lenis = null;
}
// Người dùng tắt hiệu ứng trong hệ điều hành thì giữ cuộn gốc của trình duyệt.
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) startSmooth();
// ---------- 2. Link neo: lướt tới mục, vẫn chuyển focus cho người dùng bàn phím ----------
document.addEventListener("click", (e) => {
const link = e.target.closest('a[href^="#"]');
const target = link && document.querySelector(link.getAttribute("href"));
if (!target) return;
e.preventDefault();
const focus = () => {
target.setAttribute("tabindex", "-1");
target.focus({ preventScroll: true });
};
// Lenis tự tính scroll-margin-top trong CSS, nên không cần offset ở đây.
if (lenis) lenis.scrollTo(target, { duration: 1.2, onComplete: focus });
else {
target.scrollIntoView();
focus();
}
});
// ---------- 3. Nút bật/tắt để so sánh ----------
const toggle = document.querySelector(".toggle");
toggle.setAttribute("aria-pressed", String(!reduceMotion));
toggle.querySelector("b").textContent = reduceMotion ? "Tắt" : "Bật";
toggle.addEventListener("click", () => {
if (lenis) stopSmooth();
else startSmooth();
toggle.setAttribute("aria-pressed", String(Boolean(lenis)));
toggle.querySelector("b").textContent = lenis ? "Bật" : "Tắt";
});
// ---------- 4. Hiệu ứng ScrollTrigger bình thường, không cần biết có Lenis hay không ----------
document.querySelectorAll("[data-speed]").forEach((el) => {
const speed = parseFloat(el.dataset.speed);
gsap.to(el, {
y: () => (1 - speed) * window.innerHeight * 0.6,
ease: "none",
scrollTrigger: { trigger: ".parallax", start: "top bottom", end: "bottom top", scrub: true, invalidateOnRefresh: true },
});
});
gsap.to(".fill-top", {
clipPath: "inset(0 0% 0 0)",
ease: "none",
scrollTrigger: { trigger: ".fill", start: "top 70%", end: "bottom 60%", scrub: true },
});
</script>
</body>
</html>
Code để học cách làm, không phải thư viện. Không đảm bảo chạy ngay trên site khác, dùng thì tự kiểm tra.