Tiến độ dự án cuộn ngang
Cuộn dọc nhưng nội dung chạy ngang. Đường tiến độ vẽ dần theo từng mốc.
Cách hoạt động
- 1.ScrollTrigger ghim section lại, rồi đổi cuộn dọc thành chạy ngang.
- 2.Mỗi mốc sáng lên khi chạy tới giữa màn hình, nhờ containerAnimation.
- 3.Trong khung demo thì cuộn bên trong khung. Trên trang thật thì chạy theo cuộn của trang.
Thử chỉnh
- Thêm, bớt mốc
- Copy hoặc xoá một khối .milestone trong HTML. JS tự tính lại.
- Cuộn lâu hơn mới hết
- Tăng hệ số 1.2 trong end của ScrollTrigger.
- Tắt hiệu ứng trên điện thoại
- Bọc đoạn tạo ScrollTrigger trong gsap.matchMedia("(min-width: 768px)").
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>Tiến độ dự án cuộn ngang · 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: #12151a;
--text: #f2efe9;
--muted: #8d939c;
--accent: #e2b04a;
--card: #1b2028;
--line: #2a303a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
overflow-x: hidden;
}
.intro,
.outro {
min-height: 70vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px clamp(20px, 6vw, 80px);
}
.kicker { margin: 0; color: var(--accent); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { margin: 8px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }
.timeline {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.timeline-head { padding: 0 clamp(20px, 6vw, 80px) 32px; }
.timeline-head h2 { margin: 0; font-size: clamp(24px, 3vw, 36px); }
.timeline-head p { margin: 6px 0 0; color: var(--accent); }
.track {
position: relative;
display: flex;
gap: 40px;
width: max-content;
padding: 0 clamp(20px, 6vw, 80px);
}
.line {
position: absolute;
left: 0;
right: 0;
top: 11px;
height: 3px;
background: var(--line);
}
.line-fill {
height: 100%;
background: var(--accent);
transform: scaleX(0);
transform-origin: 0 50%;
}
.milestone { position: relative; width: min(340px, 78vw); padding-top: 40px; }
.milestone::before {
content: "";
position: absolute;
top: 4px;
left: 0;
width: 17px;
height: 17px;
border-radius: 50%;
background: var(--bg);
border: 3px solid var(--line);
}
.milestone.done::before { background: var(--accent); border-color: var(--accent); }
.milestone.current::before { border-color: var(--accent); box-shadow: 0 0 0 6px rgba(226, 176, 74, 0.2); }
.milestone time { display: block; margin-bottom: 12px; font-weight: 700; font-size: 20px; }
.card { padding: 24px; border-radius: 18px; background: var(--card); }
.card h3 { margin: 12px 0 8px; font-size: 22px; }
.card p { margin: 0; color: var(--muted); line-height: 1.6; }
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--line);
}
.done .badge { background: rgba(226, 176, 74, 0.15); color: var(--accent); }
.current .badge { background: var(--accent); color: var(--bg); }
.outro { align-items: flex-start; }
.outro h2 { 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;
display: inline-block;
padding: 14px 26px;
border-radius: 999px;
background: var(--accent);
color: var(--bg);
font-weight: 700;
text-decoration: none;
}
</style>
</head>
<body>
<section class="intro">
<p class="kicker">An Phú Riverside</p>
<h1>Tiến độ xây dựng</h1>
<p class="lead">Cuộn xuống để xem từng mốc.</p>
</section>
<section class="timeline">
<div class="timeline-head">
<h2>Cập nhật tháng 09/2026</h2>
<p>Đúng tiến độ cam kết</p>
</div>
<div class="track">
<div class="line"><div class="line-fill"></div></div>
<article class="milestone done">
<time>03/2025</time>
<div class="card">
<span class="badge">Xong</span>
<h3>Khởi công</h3>
<p>Dọn mặt bằng, ép cọc thử. Đủ giấy phép xây dựng.</p>
</div>
</article>
<article class="milestone done">
<time>10/2025</time>
<div class="card">
<span class="badge">Xong</span>
<h3>Xong móng, tầng hầm</h3>
<p>2 tầng hầm, 1.100 chỗ đậu xe máy và ô tô.</p>
</div>
</article>
<article class="milestone current">
<time>09/2026</time>
<div class="card">
<span class="badge">Đang làm</span>
<h3>Cất nóc tòa A</h3>
<p>Tòa A lên tầng 20/20. Tòa B tầng 14, tòa C tầng 9.</p>
</div>
</article>
<article class="milestone">
<time>06/2027</time>
<div class="card">
<span class="badge">Sắp tới</span>
<h3>Hoàn thiện mặt ngoài</h3>
<p>Sơn, kính, cảnh quan ven sông.</p>
</div>
</article>
<article class="milestone">
<time>Q4/2027</time>
<div class="card">
<span class="badge">Sắp tới</span>
<h3>Bàn giao</h3>
<p>Bàn giao hoàn thiện cơ bản, cấp sổ hồng theo đợt.</p>
</div>
</article>
</div>
</section>
<section class="outro">
<h2>Muốn xem công trường tận nơi?</h2>
<button type="button" class="btn">Đăng ký tham quan</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>
// Tiến độ dự án cuộn ngang — bodev.vn/lab/tien-do-cuon-ngang
// Cần: gsap + ScrollTrigger.
gsap.registerPlugin(ScrollTrigger);
const track = document.querySelector(".track");
// Quãng đường phải chạy ngang = độ dài track trừ bề ngang màn hình.
const distance = () => track.scrollWidth - window.innerWidth;
// Hệ số > 1: cuộn nhiều hơn một chút mới hết, đọc thong thả hơn.
const SCROLL_FACTOR = 1.2;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
// Không ghim, không chạy ngang: cho track cuộn ngang bằng tay.
document.querySelector(".timeline").style.overflowX = "auto";
gsap.set(".line-fill", { scaleX: 1 });
} else {
// 1. Ghim section lại, kéo track sang trái theo độ cuộn.
const scroll = gsap.to(track, {
x: () => -distance(),
ease: "none",
scrollTrigger: {
trigger: ".timeline",
start: "top top",
end: () => "+=" + distance() * SCROLL_FACTOR,
pin: true,
scrub: 1,
invalidateOnRefresh: true,
},
});
// 2. Đường tiến độ vẽ dần cùng nhịp.
gsap.to(".line-fill", {
scaleX: 1,
ease: "none",
scrollTrigger: {
trigger: ".timeline",
start: "top top",
end: () => "+=" + distance() * SCROLL_FACTOR,
scrub: 1,
invalidateOnRefresh: true,
},
});
// 3. Mỗi mốc sáng lên khi chạy tới giữa màn hình.
// containerAnimation: đo vị trí theo chuyển động ngang, không theo cuộn dọc.
document.querySelectorAll(".milestone").forEach((m) => {
gsap.from(m.querySelector(".card"), {
opacity: 0.25,
y: 40,
scale: 0.92,
ease: "power2.out",
scrollTrigger: {
trigger: m,
containerAnimation: scroll,
start: "left 80%",
end: "left 45%",
scrub: true,
},
});
});
}
// Tiêu đề đầu trang.
gsap.from(".intro > *", { opacity: 0, y: 30, duration: 0.8, stagger: 0.1, ease: "expo.out" });
</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.