bodev.vn

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. 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. 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. 3.gsap.ticker.lagSmoothing(0) để khi máy chậm, cuộn không bị nhảy cóc.
  4. 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.

Tiếp trong Cơ bảnChữ co giãn theo chuột