Hành trình Hà Giang
Tuyến đường vẽ dần trên bản đồ, xe chạy theo, tới mỗi điểm dừng thì hiện ảnh và đồng hồ km nhảy số.
Cách hoạt động
- 1.Xe chạy từng chặng bằng MotionPathPlugin với start, end: mỗi tween chỉ chạy một đoạn của cùng một đường.
- 2.Vị trí điểm dừng trên tuyến (0 → 1) được đo tự động: dò điểm trên đường gần nhất với data-x, data-y. Sửa tuyến không phải tính lại.
- 3.Giữa hai chặng có quãng dừng để xem ảnh. Snap đưa về đúng các điểm dừng khi thả tay.
- 4.Chấm "đã tới" tính thẳng từ thời điểm của timeline, nên tua tới hay tua lui đều đúng.
Thử chỉnh
- Tuyến đường khác
- Sửa d của hai path tuyến đường, rồi đặt data-x, data-y của từng điểm dừng lên tuyến mới.
- Dừng ngắm lâu hơn
- Tăng STAY trong file JS.
- Thêm điểm dừng
- Copy một article.stop, đổi ảnh, toạ độ và số km.
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>Hành trình Hà Giang · 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: #132a22;
--ground: #1b3a2f;
--text: #f1ece0;
--muted: #a4b3a8;
--route: #f4c95d;
--river: #4fb3a9;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.intro,
.outro {
min-height: 70vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px clamp(20px, 6vw, 80px);
}
.kicker { margin: 0; color: var(--route); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { margin: 10px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }
.journey {
height: 100vh;
display: grid;
grid-template-columns: 1fr 1.15fr;
align-items: center;
gap: clamp(16px, 3vw, 48px);
padding: 24px clamp(20px, 5vw, 64px);
overflow: hidden;
}
.map-wrap { position: relative; height: 100%; max-height: 90vh; display: grid; place-items: center; }
.map { height: 100%; max-width: 100%; border-radius: 20px; }
.map .ground { fill: var(--ground); }
.map .peak { fill: #24493b; }
.map .peak-snow { fill: #2d5747; }
.map .border { fill: none; stroke: #c96d5b; stroke-width: 2; stroke-dasharray: 10 6; }
.map .border-label { fill: #c96d5b; font-size: 11px; letter-spacing: 0.2em; }
.map .river { fill: none; stroke: var(--river); stroke-width: 5; opacity: 0.7; }
.map .route-base { fill: none; stroke: rgba(244, 201, 93, 0.18); stroke-width: 6; stroke-dasharray: 2 10; stroke-linecap: round; }
.map .route { fill: none; stroke: var(--route); stroke-width: 6; stroke-linecap: round; }
.map .stop-dot { fill: var(--bg); stroke: var(--route); stroke-width: 3; }
.map .stop-dot.reached { fill: var(--route); }
.map .stop-name { fill: var(--text); font-size: 14px; font-weight: 700; paint-order: stroke; stroke: var(--ground); stroke-width: 4px; }
.map .rider-ring { fill: rgba(244, 201, 93, 0.25); }
.map .rider-dot { fill: #fff; stroke: var(--route); stroke-width: 3; }
.odometer {
position: absolute;
left: 16px;
bottom: 16px;
margin: 0;
padding: 8px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.odometer .km { font-size: 22px; font-weight: 800; color: var(--route); }
.stops { position: relative; height: min(560px, 80vh); }
.stop { position: absolute; inset: 0; margin: 0; opacity: 0; }
.stop img { display: block; width: 100%; aspect-ratio: 3 / 2; height: auto; object-fit: cover; border-radius: 18px; }
.stop-meta { margin: 16px 0 0; color: var(--route); font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; }
.stop h2 { margin: 6px 0 0; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.05; }
.stop p:last-child { max-width: 42ch; margin: 10px 0 0; color: var(--muted); line-height: 1.6; }
.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;
padding: 14px 26px;
border-radius: 999px;
background: var(--route);
color: var(--bg);
font-weight: 700;
}
.note { margin: 20px 0 0; font-size: 13px; color: var(--muted); }
@media (max-width: 760px) {
.journey { grid-template-columns: 1fr; grid-template-rows: 44vh 1fr; gap: 12px; }
.stops { height: 46vh; }
.stop h2 { font-size: 24px; }
.stop p:last-child { font-size: 14px; }
}
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Du lịch · Tour 3 ngày 2 đêm</p>
<h1>Cung đường Hà Giang</h1>
<p class="lead">Cuộn xuống để chạy hết 170 km.</p>
</section>
<section class="journey">
<div class="map-wrap">
<svg class="map" viewBox="0 0 600 700" role="img" aria-label="Bản đồ tuyến đường Hà Giang">
<rect width="600" height="700" class="ground" />
<!-- Núi đá: JS rải thêm các chóp núi vào đây -->
<g class="peaks"></g>
<path class="border" d="M40 70 C 160 40, 260 90, 360 60 S 520 40, 580 90" />
<text class="border-label" x="60" y="56">BIÊN GIỚI</text>
<path class="river" d="M250 700 C 270 640, 330 620, 320 560 S 280 500, 300 420" />
<path class="river" d="M600 150 C 540 170, 500 210, 470 260 S 430 330, 460 420" />
<!-- Tuyến đường: nét vẽ dần và xe chạy theo -->
<path id="route" class="route-base" d="M300 640 C 290 560, 250 520, 262 470 C 272 420, 360 430, 392 380 C 424 330, 330 262, 342 204 C 352 168, 420 158, 470 182 C 512 200, 522 244, 500 286" />
<path class="route" d="M300 640 C 290 560, 250 520, 262 470 C 272 420, 360 430, 392 380 C 424 330, 330 262, 342 204 C 352 168, 420 158, 470 182 C 512 200, 522 244, 500 286" />
<g class="stop-dots"></g>
<g class="rider">
<circle r="13" class="rider-ring" />
<circle r="6" class="rider-dot" />
</g>
</svg>
<p class="odometer"><span class="km">0</span> km</p>
</div>
<!-- Mỗi điểm dừng: data-x, data-y là vị trí trên bản đồ (phải nằm trên tuyến đường). -->
<div class="stops" aria-live="polite">
<article class="stop" data-x="300" data-y="640" data-km="0">
<img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/thanh-pho.webp" alt="Thành phố Hà Giang và sông Lô trong sương sớm" width="1200" height="806" />
<p class="stop-meta">Ngày 1 · Km 0</p>
<h2>Thành phố Hà Giang</h2>
<p>Điểm xuất phát. Thuê xe máy, ăn bát phở sáng rồi lên đường.</p>
</article>
<article class="stop" data-x="262" data-y="470" data-km="45">
<img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/quan-ba.webp" alt="Núi đôi giữa ruộng bậc thang ở Quản Bạ" width="1200" height="806" />
<p class="stop-meta">Ngày 1 · Km 45</p>
<h2>Quản Bạ</h2>
<p>Qua Cổng Trời, nhìn xuống thung lũng có hai quả núi đôi tròn như vẽ.</p>
</article>
<article class="stop" data-x="392" data-y="380" data-km="100">
<img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/yen-minh.webp" alt="Đường đèo uốn lượn qua rừng thông Yên Minh" width="1200" height="806" />
<p class="stop-meta">Ngày 1 · Km 100</p>
<h2>Yên Minh</h2>
<p>Rừng thông hai bên, đường đèo lượn liên tục. Nghỉ đêm đầu tiên ở đây.</p>
</article>
<article class="stop" data-x="342" data-y="204" data-km="150">
<img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/dong-van.webp" alt="Làng nhà trình tường giữa cao nguyên đá Đồng Văn" width="1200" height="806" />
<p class="stop-meta">Ngày 2 · Km 150</p>
<h2>Đồng Văn</h2>
<p>Cao nguyên đá, nhà trình tường mái âm dương. Tối dạo phố cổ.</p>
</article>
<article class="stop" data-x="500" data-y="286" data-km="170">
<img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/ma-pi-leng.webp" alt="Đèo Mã Pí Lèng và sông Nho Quế xanh ngọc dưới hẻm vực" width="1200" height="806" />
<p class="stop-meta">Ngày 3 · Km 170</p>
<h2>Mã Pí Lèng</h2>
<p>Con đèo đẹp nhất. Dưới vực là sông Nho Quế xanh ngọc.</p>
</article>
</div>
</section>
<section class="outro">
<h2>Sẵn sàng lên đường?</h2>
<button type="button" class="btn">Giữ chỗ tour tháng này</button>
<p class="note">Ảnh trong demo là minh họa tạo bằng AI.</p>
</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/gsap@3.15.0/dist/MotionPathPlugin.min.js"></script>
<script>
// Hành trình Hà Giang — bodev.vn/lab/hanh-trinh-ha-giang
// Cần: gsap + ScrollTrigger + MotionPathPlugin.
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
const NS = "http://www.w3.org/2000/svg";
const route = document.querySelector(".route");
const routeBase = document.querySelector("#route");
const stops = [...document.querySelectorAll(".stop")];
const kmLabel = document.querySelector(".odometer .km");
const length = route.getTotalLength();
// ---------- 1. Núi đá trang trí: rải ngẫu nhiên nhưng cố định (seed), lần nào tải cũng giống nhau ----------
let seed = 7;
const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const peaks = document.querySelector(".peaks");
for (let i = 0; i < 70; i++) {
const x = random() * 600;
const y = 90 + random() * 610;
const h = 18 + random() * 34;
const w = h * (0.6 + random() * 0.4);
const peak = document.createElementNS(NS, "path");
peak.setAttribute("class", random() > 0.7 ? "peak-snow" : "peak");
// Chóp núi đá vôi: đáy rộng, đỉnh tròn.
peak.setAttribute("d", `M${x - w} ${y} Q${x - w * 0.3} ${y - h} ${x} ${y - h} Q${x + w * 0.3} ${y - h} ${x + w} ${y} Z`);
peaks.appendChild(peak);
}
// ---------- 2. Vị trí mỗi điểm dừng trên tuyến (0 → 1) ----------
// Đo tự động: dò điểm gần nhất trên đường với toạ độ data-x, data-y. Sửa tuyến không phải tính lại tay.
function fractionAt(x, y) {
const SAMPLES = 600;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i <= SAMPLES; i++) {
const p = route.getPointAtLength((i / SAMPLES) * length);
const d = (p.x - x) ** 2 + (p.y - y) ** 2;
if (d < bestDist) [best, bestDist] = [i / SAMPLES, d];
}
return best;
}
const fractions = stops.map((s) => fractionAt(Number(s.dataset.x), Number(s.dataset.y)));
// Chấm và tên điểm dừng trên bản đồ.
const dotsBox = document.querySelector(".stop-dots");
const dots = stops.map((s, i) => {
const x = Number(s.dataset.x);
const y = Number(s.dataset.y);
const dot = document.createElementNS(NS, "circle");
dot.setAttribute("class", "stop-dot");
dot.setAttribute("cx", x);
dot.setAttribute("cy", y);
dot.setAttribute("r", 7);
const name = document.createElementNS(NS, "text");
name.setAttribute("class", "stop-name");
name.setAttribute("x", x + (i % 2 ? -14 : 14));
name.setAttribute("y", y + 5);
name.setAttribute("text-anchor", i % 2 ? "end" : "start");
name.textContent = s.querySelector("h2").textContent;
dotsBox.append(dot, name);
return dot;
});
// ---------- 3. Timeline: chạy từng chặng, tới nơi thì dừng ngắm ----------
gsap.set(route, { strokeDasharray: length, strokeDashoffset: length });
gsap.set(stops[0], { opacity: 1 });
dots[0].classList.add("reached");
const odometer = { km: 0 };
const LEG = 1; // thời lượng một chặng
const STAY = 0.6; // dừng ngắm ở mỗi điểm
const tl = gsap.timeline({ defaults: { ease: "power1.inOut" } });
// Đặt xe ở điểm xuất phát.
tl.set(".rider", { motionPath: { path: routeBase, align: routeBase, alignOrigin: [0.5, 0.5], start: fractions[0], end: fractions[0] } }, 0);
stops.slice(1).forEach((stop, k) => {
const i = k + 1;
const at = k * (LEG + STAY) + STAY;
const from = fractions[i - 1];
const to = fractions[i];
tl.to(".rider", { motionPath: { path: routeBase, align: routeBase, alignOrigin: [0.5, 0.5], start: from, end: to }, duration: LEG }, at)
// Nét đường vẽ theo đúng đoạn xe vừa chạy.
.fromTo(route, { strokeDashoffset: length * (1 - from) }, { strokeDashoffset: length * (1 - to), duration: LEG, immediateRender: false }, at)
.to(odometer, { km: Number(stop.dataset.km), duration: LEG, onUpdate: () => (kmLabel.textContent = Math.round(odometer.km)) }, at)
.to(stops[i - 1], { opacity: 0, y: -20, duration: 0.3 }, at)
// Hiện đủ đúng lúc xe tới nơi: snap dừng ở thời điểm đó, thẻ không được lưng chừng.
.fromTo(stops[i], { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.4 }, at + LEG - 0.4);
});
// Chấm "đã tới" tính thẳng từ thời điểm hiện tại: tua tới hay tua lui đều đúng.
const arriveAt = stops.map((_, i) => i * (LEG + STAY));
tl.eventCallback("onUpdate", () => dots.forEach((dot, i) => dot.classList.toggle("reached", tl.time() >= arriveAt[i])));
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
// Không chạy: hiện cả tuyến, cho xem điểm cuối.
tl.progress(1);
} else {
ScrollTrigger.create({
trigger: ".journey",
start: "top top",
end: () => "+=" + window.innerHeight * stops.length,
pin: true,
scrub: 1,
animation: tl,
// Thả tay là dừng đúng ở một điểm dừng.
snap: {
snapTo: arriveAt.map((t) => t / tl.duration()),
inertia: false,
directional: false, // về điểm dừng gần nhất, không nhảy quá một điểm theo hướng cuộn
duration: { min: 0.2, max: 0.6 },
},
});
}
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.