Tách tầng công trình
Tòa nhà isometric ráp lại rồi tách từng tầng theo cuộn. Mỗi tầng sáng lên kèm công năng, diện tích.
Cách hoạt động
- 1.Mỗi tầng là một nhóm SVG: sàn, tường thấp, cột, đồ bên trong. Nhãn tên tầng nằm trong nhóm nên đi theo tầng khi tách.
- 2.Tách tầng chỉ là dịch nhóm lên theo trục đứng màn hình, tầng k dịch k × GAP pixel.
- 3.Khi xem từng tầng, các tầng khác mờ đi, bảng bên phải đổi sang thông tin tầng đó.
Thử chỉnh
- Đổi số tầng, công năng
- Sửa mảng FLOORS: tên, tiêu đề, mô tả, số liệu, và hàm build() vẽ đồ bên trong bằng box(...).
- Tách xa hơn
- Tăng GAP trong file JS.
- Đổi kích thước sàn
- Sửa W, D (dài, rộng) và FLOOR_H (cao tầng).
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>Tách tầng công trình · 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: #eef0ec;
--ink: #1f2a2e;
--muted: #6b7773;
--accent: #2a9d8f;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
}
.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: 10px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }
.explode {
height: 100vh;
display: grid;
grid-template-columns: 1.4fr 1fr;
align-items: center;
gap: 24px;
padding: 24px clamp(20px, 5vw, 64px);
overflow: hidden;
}
.building { width: 100%; height: 100%; max-height: 90vh; overflow: visible; }
.building .label { font-size: 15px; font-weight: 700; fill: var(--ink); }
.building .leader { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; }
.panel-kicker { margin: 0 0 12px; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); }
.infos { position: relative; min-height: 220px; }
.info { position: absolute; inset: 0; opacity: 0; }
.info h2 { margin: 0; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.05; }
.info p { max-width: 34ch; margin: 12px 0 0; line-height: 1.6; color: var(--muted); }
.info dl { display: flex; gap: 28px; margin: 18px 0 0; }
.info dt { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.info dd { margin: 4px 0 0; font-size: 22px; font-weight: 700; }
.outro { align-items: flex-start; }
.outro h2 { max-width: 20ch; 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) {
.explode { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.building { max-height: 58vh; }
.infos { min-height: 170px; }
.info p { font-size: 14px; }
}
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Kiến trúc · Văn phòng 4 tầng</p>
<h1>Bên trong có gì</h1>
<p class="lead">Cuộn xuống để tách từng tầng.</p>
</section>
<section class="explode">
<!-- JS dựng tòa nhà vào đây. Danh sách tầng nằm ở mảng FLOORS trong file JS. -->
<svg class="building" viewBox="-150 -520 540 730" role="img" aria-label="Tòa nhà tách tầng"></svg>
<div class="panel" aria-live="polite">
<p class="panel-kicker">Mặt bằng công năng</p>
<div class="infos"></div>
</div>
</section>
<section class="outro">
<h2>Cần thể hiện công năng dự án rõ như vậy?</h2>
<button type="button" class="btn">Trao đổi với kiến trúc sư</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>
// Tách tầng công trình — bodev.vn/lab/tach-tang-cong-trinh
// Cần: gsap + ScrollTrigger.
gsap.registerPlugin(ScrollTrigger);
// ---------- 1. Vẽ isometric (giống công thức "Lắp ráp căn phòng") ----------
const UNIT = 22;
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const iso = (x, y, z = 0) => [(x - y) * COS * UNIT, (x + y) * SIN * UNIT - z * UNIT];
const pts = (...list) => list.map((p) => iso(...p).map((n) => n.toFixed(1)).join(",")).join(" ");
const NS = "http://www.w3.org/2000/svg";
const svg = document.querySelector(".building");
function el(tag, attrs, parent = svg) {
const node = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
parent.appendChild(node);
return node;
}
function shade(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const f = (c) => Math.round(c * (1 - amount));
return `rgb(${f(n >> 16)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
}
function box(g, x, y, z, w, d, h, color) {
el("polygon", { points: pts([x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]), fill: shade(color, 0.12) }, g);
el("polygon", { points: pts([x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]), fill: shade(color, 0.25) }, g);
el("polygon", { points: pts([x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]), fill: color }, g);
}
function tree(g, x, y, z, r) {
box(g, x - 0.05, y - 0.05, z, 0.1, 0.1, r * 1.2, "#6b4a33");
const [cx, cy] = iso(x, y, z + r * 1.6);
el("circle", { cx, cy, r: r * UNIT * 0.9, fill: "#5d8a5a" }, g);
}
// ---------- 2. Các tầng: sửa mảng này cho dự án của bạn ----------
const W = 10; // chiều dài (trục x)
const D = 6; // chiều rộng (trục y)
const FLOOR_H = 2.4; // chiều cao tầng
const SLAB = 0.3;
const FLOORS = [
{
name: "Tầng 1",
title: "Sảnh & café",
desc: "Sảnh đón mở ra phố, quầy café bên trái, bàn ngồi sát kính.",
stats: [["Diện tích", "520 m²"], ["Công năng", "Công cộng"]],
build(g, z) {
box(g, 0.8, 0.8, z, 3.2, 0.9, 1, "#a47551");
for (const [x, y] of [[5.5, 3.2], [7.8, 3.2], [5.5, 4.8], [7.8, 4.8]]) box(g, x, y, z, 0.9, 0.9, 0.7, "#e9e4da");
tree(g, 2, 4.6, z, 0.7);
},
},
{
name: "Tầng 2",
title: "Văn phòng mở",
desc: "40 chỗ ngồi, bàn xếp theo cụm 4 người, lối đi ven kính.",
stats: [["Diện tích", "540 m²"], ["Chỗ ngồi", "40"]],
build(g, z) {
for (let i = 0; i < 4; i++) for (let j = 0; j < 2; j++) box(g, 1.2 + i * 2.1, 1.2 + j * 2.3, z, 1.6, 1.2, 0.55, "#f4f1ea");
},
},
{
name: "Tầng 3",
title: "Phòng họp & thư viện",
desc: "2 phòng họp vách kính, kệ sách chạy dọc tường, góc đọc yên tĩnh.",
stats: [["Diện tích", "540 m²"], ["Phòng họp", "2"]],
build(g, z) {
box(g, 0.3, 0.8, z, 0.6, 4.4, 1.9, "#8a6244");
box(g, 5, 0.8, z, 3.6, 1.4, 0.55, "#f4f1ea");
box(g, 5, 3.4, z, 3.6, 1.4, 0.55, "#f4f1ea");
el("polygon", { points: pts([4.5, 2.8, z], [9.3, 2.8, z], [9.3, 2.8, z + 1.8], [4.5, 2.8, z + 1.8]), fill: "rgba(160,200,215,0.35)", stroke: "#8fb3c2" }, g);
},
},
{
name: "Mái",
title: "Sân vườn trên mái",
desc: "Vườn rau, cây bóng mát và giàn năng lượng mặt trời.",
stats: [["Diện tích", "480 m²"], ["Cây xanh", "35%"]],
roof: true,
build(g, z) {
box(g, 0.6, 0.6, z, 3.4, 2.2, 0.25, "#7aa26a");
box(g, 0.6, 3.2, z, 3.4, 2.2, 0.25, "#7aa26a");
tree(g, 6, 2, z, 0.9);
tree(g, 8.4, 4.3, z, 0.7);
el("polygon", { points: pts([5.2, 3.6, z + 1.2], [7.6, 3.6, z + 1.2], [7.6, 5.4, z + 0.6], [5.2, 5.4, z + 0.6]), fill: "#2c4a63" }, g);
},
},
];
// ---------- 3. Dựng tòa nhà ----------
const floorGroups = FLOORS.map((floor, k) => {
const z = k * FLOOR_H;
const g = el("g", { class: "floor" });
box(g, 0, 0, z, W, D, SLAB, floor.roof ? "#c9c4b8" : "#dcd7cc");
// tường thấp hai cạnh sau, để nhìn thấy bên trong
box(g, 0, 0, z + SLAB, W, 0.15, 0.8, "#e9e5dc");
box(g, 0, 0, z + SLAB, 0.15, D, 0.8, "#e9e5dc");
floor.build(g, z + SLAB);
if (!floor.roof) {
for (const [x, y] of [[0, 0], [W - 0.3, 0], [0, D - 0.3], [W - 0.3, D - 0.3]]) box(g, x, y, z + SLAB, 0.3, 0.3, FLOOR_H - SLAB, "#cfc9bd");
}
// nhãn + đường dẫn, nằm trong group nên đi theo tầng khi tách
const [ax, ay] = iso(W, 0, z + SLAB);
const label = el("g", { class: "tag", opacity: 0 }, g);
el("line", { class: "leader", x1: ax, y1: ay, x2: ax + 60, y2: ay }, label);
const text = el("text", { class: "label", x: ax + 66, y: ay + 5 }, label);
text.textContent = floor.name;
return g;
});
// Bảng thông tin bên phải
const infos = document.querySelector(".infos");
const overview = { title: "4 tầng · 2.080 m² sàn", desc: "Văn phòng nhỏ cho 60 người, tầng trệt mở cho khách.", stats: [["Hoàn thành", "2026"], ["Vị trí", "Đà Nẵng"]] };
const cards = [overview, ...FLOORS].map((f) => {
const card = document.createElement("div");
card.className = "info";
const h = document.createElement("h2");
h.textContent = f.name ? `${f.name}: ${f.title}` : f.title;
const p = document.createElement("p");
p.textContent = f.desc;
const dl = document.createElement("dl");
for (const [k, v] of f.stats) {
const wrap = document.createElement("div");
const dt = document.createElement("dt");
dt.textContent = k;
const dd = document.createElement("dd");
dd.textContent = v;
wrap.append(dt, dd);
dl.appendChild(wrap);
}
card.append(h, p, dl);
infos.appendChild(card);
return card;
});
gsap.set(cards[0], { opacity: 1 });
// ---------- 4. Cuộn: ráp → tách → xem từng tầng ----------
const GAP = 95; // px tách giữa hai tầng
const tl = gsap.timeline({ defaults: { ease: "power3.inOut", duration: 1 } });
tl.from(floorGroups, { y: -160, opacity: 0, stagger: 0.25, ease: "power3.out" }, 0);
floorGroups.forEach((g, k) => tl.to(g, { y: -k * GAP }, 1.6));
tl.to(".tag", { opacity: 1, duration: 0.4, stagger: 0.1 }, 2.4);
FLOORS.forEach((_, i) => {
const at = 3.2 + i;
tl.to(floorGroups, { opacity: 0.25, duration: 0.4 }, at)
.to(floorGroups[i], { opacity: 1, duration: 0.4 }, at)
.to(cards, { opacity: 0, y: -12, duration: 0.3 }, at)
.fromTo(cards[i + 1], { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.3 }, at + 0.2);
});
tl.to(floorGroups, { opacity: 1, duration: 0.4 }, 3.2 + FLOORS.length);
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
tl.progress(1);
} else {
ScrollTrigger.create({
trigger: ".explode",
start: "top top",
end: "+=3200",
pin: true,
scrub: 1,
animation: tl,
});
}
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.