bodev.vn

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. 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. 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. 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.

Tiếp trong Bất động sảnChọn tầng, xem căn trống