Mở màn trang dự án
Tên dự án hiện từng chữ, tòa nhà mọc lên, số liệu tự đếm.
Cách hoạt động
- 1.Section đầu tiên của trang dự án. Khách vào là thấy ngay tên, quy mô, ngày bàn giao.
- 2.Tòa nhà vẽ bằng SVG, không cần ảnh. Muốn dùng ảnh phối cảnh thật thì thay khối SVG bằng thẻ img, hiệu ứng vẫn giữ.
- 3.Số liệu đếm từ 0 lên. Sửa con số ngay trong HTML, JS tự đọc.
Thử chỉnh
- Đổi tên dự án, số liệu
- Sửa chữ trong HTML. Con số nằm ở thuộc tính data-count.
- Đổi màu chủ đạo
- Sửa --accent ở đầu file CSS.
- Chạy chậm hơn
- Tăng timeScale ở cuối file JS, ví dụ 0.7.
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>Mở màn trang dự án · 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: #0f2a24;
--text: #f4efe4;
--muted: #a9b8b0;
--accent: #d4a857;
--tower: #1d4a3f;
--window: #f6d98b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.hero {
min-height: 100vh;
display: grid;
grid-template-columns: 1.1fr 1fr;
align-items: center;
gap: 32px;
padding: 48px clamp(20px, 6vw, 80px);
overflow: hidden;
}
.hero-kicker {
margin: 0 0 16px;
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.hero-title {
margin: 0;
font-size: clamp(44px, 7vw, 96px);
font-weight: 800;
line-height: 0.95;
letter-spacing: -0.02em;
}
/* JS wraps each letter; the mask hides letters until they rise into place. */
.hero-title .word { display: inline-block; white-space: nowrap; }
.hero-title .mask { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.08em; }
.hero-title .char { display: inline-block; }
.hero-sub {
max-width: 440px;
margin: 20px 0 0;
font-size: 17px;
line-height: 1.6;
color: var(--muted);
}
.hero-stats {
display: flex;
gap: clamp(20px, 4vw, 48px);
margin: 36px 0 0;
padding-top: 24px;
border-top: 1px solid rgba(244, 239, 228, 0.15);
}
.hero-stats dt { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.hero-stats dd { margin: 6px 0 0; font-size: clamp(24px, 3vw, 36px); font-weight: 700; font-variant-numeric: tabular-nums; }
.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 36px; }
.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;
font-weight: 700;
text-decoration: none;
/* Hover dùng translate, không dùng transform: tránh đá nhau với GSAP. */
transition: translate 0.2s, background-color 0.2s;
}
.btn:hover { translate: 0 -2px; }
.btn-solid { background: var(--accent); color: var(--bg); }
.btn-ghost { border: 1.5px solid rgba(244, 239, 228, 0.4); background: transparent; color: var(--text); }
.btn-ghost:hover { background: rgba(244, 239, 228, 0.08); }
.hero-art { width: 100%; max-height: 80vh; }
.hero-art .sun { fill: var(--accent); opacity: 0.9; }
.hero-art .tower rect { fill: var(--tower); }
.hero-art .tower .win { fill: var(--window); }
.hero-art .river { fill: #173a33; }
@media (max-width: 760px) {
.hero { grid-template-columns: 1fr; padding-top: 32px; }
.hero-art { max-height: 320px; order: -1; }
}
</style>
</head>
<body>
<section class="hero">
<div class="hero-copy">
<p class="hero-kicker">Dự án · Thủ Đức, TP.HCM</p>
<h1 class="hero-title" data-split>An Phú Riverside</h1>
<p class="hero-sub">3 tòa căn hộ ven sông, cách trung tâm 15 phút. Bàn giao hoàn thiện cơ bản.</p>
<dl class="hero-stats">
<div>
<dt>Diện tích</dt>
<dd><span data-count="3.2" data-decimals="1">3,2</span> ha</dd>
</div>
<div>
<dt>Số căn</dt>
<dd><span data-count="1240">1.240</span></dd>
</div>
<div>
<dt>Bàn giao</dt>
<dd>Q4/<span data-count="2027" data-plain>2027</span></dd>
</div>
</dl>
<div class="hero-cta">
<button type="button" class="btn btn-solid">Nhận bảng giá</button>
<button type="button" class="btn btn-ghost">Xem mặt bằng</button>
</div>
</div>
<svg class="hero-art" viewBox="0 0 400 420" role="img" aria-label="Phối cảnh 3 tòa nhà">
<circle class="sun" cx="300" cy="90" r="46" />
<g class="tower" data-floors="14">
<rect x="40" y="130" width="90" height="270" rx="4" />
</g>
<g class="tower" data-floors="18">
<rect x="150" y="60" width="100" height="340" rx="4" />
</g>
<g class="tower" data-floors="11">
<rect x="270" y="190" width="90" height="210" rx="4" />
</g>
<path class="river" d="M0 400 C 80 385, 160 415, 240 400 S 360 390, 400 402 L400 420 L0 420 Z" />
</svg>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
// Mở màn trang dự án — bodev.vn/lab/mo-man-du-an
// Cần: gsap (core). Không cần plugin.
// 1. Tách tiêu đề thành từng chữ, giữ nguyên từng từ để không bị xuống dòng giữa từ.
function splitLetters(el) {
const words = el.textContent.trim().split(/\s+/);
el.setAttribute("aria-label", el.textContent.trim());
el.textContent = "";
words.forEach((word, i) => {
const w = document.createElement("span");
w.className = "word";
w.setAttribute("aria-hidden", "true");
Array.from(word.normalize("NFC")).forEach((ch) => {
const mask = document.createElement("span");
mask.className = "mask";
const c = document.createElement("span");
c.className = "char";
c.textContent = ch;
mask.appendChild(c);
w.appendChild(mask);
});
el.appendChild(w);
if (i < words.length - 1) el.appendChild(document.createTextNode(" "));
});
return el.querySelectorAll(".char");
}
// 2. Vẽ cửa sổ cho từng tòa theo số tầng trong data-floors.
function drawWindows(tower) {
const rect = tower.querySelector("rect");
const x = +rect.getAttribute("x");
const y = +rect.getAttribute("y");
const w = +rect.getAttribute("width");
const h = +rect.getAttribute("height");
const floors = +tower.dataset.floors;
const cols = 3;
const rowH = h / (floors + 1);
for (let f = 0; f < floors; f++) {
for (let c = 0; c < cols; c++) {
const win = document.createElementNS("http://www.w3.org/2000/svg", "rect");
win.setAttribute("class", "win");
win.setAttribute("x", x + 12 + c * ((w - 24) / cols));
win.setAttribute("y", y + rowH * (f + 0.6));
win.setAttribute("width", (w - 24) / cols - 8);
win.setAttribute("height", rowH * 0.45);
tower.appendChild(win);
}
}
}
// 3. Đếm số từ 0. Định dạng kiểu Việt Nam: 1.240 và 3,2.
function countUp(tl, el, at) {
const target = parseFloat(el.dataset.count);
const decimals = parseInt(el.dataset.decimals || "0", 10);
const plain = el.hasAttribute("data-plain");
const from = plain ? target - 3 : 0;
const counter = { v: from };
const format = (n) =>
plain ? String(Math.round(n)) : n.toLocaleString("vi-VN", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
tl.to(counter, { v: target, duration: 1.6, ease: "power3.out", onUpdate: () => (el.textContent = format(counter.v)) }, at);
}
const chars = splitLetters(document.querySelector("[data-split]"));
document.querySelectorAll(".tower").forEach(drawWindows);
const tl = gsap.timeline({ defaults: { ease: "expo.out" } });
tl.from(".hero-kicker", { opacity: 0, y: 12, duration: 0.6 })
.from(chars, { yPercent: 110, duration: 1, stagger: 0.03 }, 0.1)
.from(".tower rect:first-child", { scaleY: 0, transformOrigin: "50% 100%", duration: 1.2, stagger: 0.15, ease: "power4.out" }, 0.2)
.from(".sun", { y: 120, opacity: 0, duration: 1.6, ease: "power2.out" }, 0.2)
// Cửa sổ sáng lên sau khi tòa cao nhất mọc xong.
.from(".win", { opacity: 0, duration: 0.2, stagger: { each: 0.008, from: "random" } }, 1.6)
.from(".hero-sub", { opacity: 0, y: 16, duration: 0.8 }, 0.7)
.from(".hero-stats > div", { opacity: 0, y: 20, duration: 0.7, stagger: 0.1 }, 0.9)
.from(".hero-cta .btn", { opacity: 0, y: 16, duration: 0.6, stagger: 0.1 }, 1.3);
document.querySelectorAll("[data-count]").forEach((el) => countUp(tl, el, 0.9));
// Người dùng tắt hiệu ứng trong hệ điều hành thì hiện luôn trạng thái cuối.
if (matchMedia("(prefers-reduced-motion: reduce)").matches) tl.progress(1);
tl.timeScale(1);
</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.