Lắp ráp căn phòng
Phòng isometric dựng dần theo cuộn: sàn, tường, đồ đạc rơi vào chỗ, cuối cùng bật đèn.
Cách hoạt động
- 1.Cả căn phòng vẽ bằng SVG từ JS. Hàm box(x, y, z, rộng, sâu, cao, màu) dựng một khối hộp 3 mặt, sofa hay bàn chỉ là vài khối ghép lại.
- 2.Mỗi nhóm đồ có data-step. Timeline cho từng bước hiện ra, ScrollTrigger gắn timeline vào thanh cuộn (scrub).
- 3.Bước cuối phủ một lớp tối lên phòng rồi bật quầng sáng quanh đèn, nhìn như chuyển sang buổi tối.
Thử chỉnh
- Thêm đồ nội thất
- Tạo group(bước) rồi gọi box(...) với toạ độ trong phòng 10×10. Vẽ đồ ở trong trước, ngoài sau.
- Đổi màu sàn, tường
- Sửa mã màu trong phần "2. Căn phòng" của file JS.
- Cuộn lâu hơn mới dựng xong
- Tăng end: "+=2600" trong ScrollTrigger.
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>Lắp ráp căn phòng · 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: #efe9df;
--text: #2a2622;
--muted: #8a8177;
--accent: #b5643c;
}
* { 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(--accent); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { max-width: 12ch; margin: 10px 0 0; font-size: clamp(38px, 6.5vw, 80px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }
.build {
background: var(--bg);
height: 100vh;
display: grid;
grid-template-columns: minmax(180px, 280px) 1fr;
align-items: center;
gap: 24px;
padding: 24px clamp(20px, 5vw, 64px);
overflow: hidden;
}
.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 14px; }
.step { font-size: 18px; font-weight: 500; opacity: 0.3; }
.step b { display: inline-block; width: 2.4em; color: var(--accent); }
.room { width: 100%; height: 100%; max-height: 88vh; overflow: hidden; }
.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: #fff;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 760px) {
.build { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.steps { grid-auto-flow: column; overflow: hidden; gap: 10px; }
.step { font-size: 13px; white-space: nowrap; }
.step b { width: auto; margin-right: 4px; }
}
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Nội thất · Phòng khách 25m²</p>
<h1>Từ căn phòng trống tới lúc bật đèn</h1>
<p class="lead">Cuộn xuống để dựng phòng.</p>
</section>
<section class="build">
<ol class="steps">
<li class="step" data-step="0"><b>01</b> Sàn gỗ sồi</li>
<li class="step" data-step="1"><b>02</b> Tường, cửa sổ</li>
<li class="step" data-step="2"><b>03</b> Sofa, thảm, bàn</li>
<li class="step" data-step="3"><b>04</b> Kệ, cây, đèn</li>
<li class="step" data-step="4"><b>05</b> Bật đèn</li>
</ol>
<!-- JS vẽ căn phòng vào đây -->
<svg class="room" viewBox="-260 -215 520 510" role="img" aria-label="Phòng khách dựng dần theo cuộn"></svg>
</section>
<section class="outro">
<h2>Muốn thấy phòng mình trông thế nào?</h2>
<button type="button" class="btn">Đặt lịch khảo sát miễn phí</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>
// Lắp ráp căn phòng — bodev.vn/lab/lap-rap-can-phong
// Cần: gsap + ScrollTrigger.
gsap.registerPlugin(ScrollTrigger);
// ---------- 1. Vẽ isometric ----------
// Toạ độ phòng: x, y trên sàn (0 → 10), z là chiều cao. Mỗi đơn vị = 26px.
const UNIT = 26;
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(".room");
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;
}
// Làm tối màu một chút cho mặt bên, để khối có chiều sâu.
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)})`;
}
// Một khối hộp: vẽ 3 mặt nhìn thấy (trên, phải, trước).
function box(group, 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) }, group);
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) }, group);
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 }, group);
}
// Mọi thứ trừ cây đèn nằm trong scene, để lúc tối chỉ làm tối scene, đèn vẫn sáng.
const scene = el("g", { class: "scene" });
const group = (step, parent = scene) => el("g", { class: "piece", "data-step": step }, parent);
// ---------- 2. Căn phòng ----------
const ROOM = 10;
const WALL = 7;
const floor = group(0);
el("polygon", { points: pts([0, 0], [ROOM, 0], [ROOM, ROOM], [0, ROOM]), fill: "#d6b48a" }, floor);
for (let i = 1; i < ROOM; i++) {
el("polyline", { points: pts([i, 0], [i, ROOM]), stroke: "#c9a67b", "stroke-width": 1, fill: "none" }, floor);
}
const walls = group(1);
el("polygon", { points: pts([0, 0, 0], [0, ROOM, 0], [0, ROOM, WALL], [0, 0, WALL]), fill: "#e9e2d6" }, walls);
el("polygon", { points: pts([0, 0, 0], [ROOM, 0, 0], [ROOM, 0, WALL], [0, 0, WALL]), fill: "#f4efe7" }, walls);
// Cửa sổ trên tường phải, tranh trên tường trái.
el("polygon", { points: pts([1.8, 0, 2.6], [4.6, 0, 2.6], [4.6, 0, 6], [1.8, 0, 6]), fill: "#bcd3dd" }, walls);
el("polyline", { points: pts([3.2, 0, 2.6], [3.2, 0, 6]), stroke: "#fff", "stroke-width": 3, fill: "none" }, walls);
el("polygon", { points: pts([0, 3.2, 3.4], [0, 6.2, 3.4], [0, 6.2, 5.4], [0, 3.2, 5.4]), fill: "#b5643c" }, walls);
el("polygon", { points: pts([0, 3.6, 3.8], [0, 5.8, 3.8], [0, 5.8, 5], [0, 3.6, 5]), fill: "#e8b27c" }, walls);
// Đồ vẽ từ trong ra ngoài (x + y nhỏ trước) để món trước che món sau cho đúng.
const rug = group(2);
box(rug, 2.4, 3, 0, 5, 4.6, 0.06, "#9fae9a");
const sofa = group(2);
box(sofa, 0.3, 2.4, 0, 1.8, 5, 1.1, "#5f7d8c");
box(sofa, 0.3, 2.4, 1.1, 0.6, 5, 1.3, "#5f7d8c");
box(sofa, 0.3, 2.4, 1.1, 1.8, 0.5, 0.6, "#56727f");
box(sofa, 0.3, 6.9, 1.1, 1.8, 0.5, 0.6, "#56727f");
box(sofa, 0.95, 3.1, 1.1, 0.35, 1, 0.8, "#e8b27c");
const table = group(2);
box(table, 3.6, 4.2, 0, 0.2, 0.2, 0.8, "#6b4a33");
box(table, 5.4, 5.9, 0, 0.2, 0.2, 0.8, "#6b4a33");
box(table, 3.4, 4, 0.8, 2.4, 2.2, 0.18, "#8a6244");
const shelf = group(3);
box(shelf, 5.2, 0.1, 0, 3.6, 1.1, 2.2, "#8a6244");
box(shelf, 5.6, 0.3, 2.2, 0.7, 0.6, 0.9, "#b5643c");
box(shelf, 7.4, 0.3, 2.2, 0.5, 0.5, 1.4, "#e9e2d6");
const plant = group(3);
box(plant, 8.9, 1.6, 0, 0.8, 0.8, 0.9, "#c9b8a3");
const [px, py] = iso(9.3, 2, 2.1);
el("ellipse", { cx: px, cy: py, rx: 26, ry: 34, fill: "#5d7f55" }, plant);
el("ellipse", { cx: px - 14, cy: py + 10, rx: 16, ry: 22, fill: "#6f9464" }, plant);
const lamp = group(3, svg);
box(lamp, 0.7, 8.4, 0, 0.9, 0.9, 0.15, "#2a2622");
box(lamp, 1.08, 8.78, 0.15, 0.14, 0.14, 4.6, "#2a2622");
box(lamp, 0.55, 8.25, 4.6, 1.2, 1.2, 1, "#f1e4c8");
// Lớp tối (buổi tối) và quầng sáng của đèn: hiện ở bước cuối.
const [lx, ly] = iso(1.15, 8.85, 4.2);
const defs = el("defs", {});
const glowGrad = el("radialGradient", { id: "glow" }, defs);
el("stop", { offset: "0%", "stop-color": "#ffd98a", "stop-opacity": 0.9 }, glowGrad);
el("stop", { offset: "100%", "stop-color": "#ffd98a", "stop-opacity": 0 }, glowGrad);
const glow = el("circle", { cx: lx, cy: ly, r: 150, fill: "url(#glow)", opacity: 0, class: "glow" });
svg.appendChild(lamp); // đèn vẽ sau cùng, nằm ngoài scene
// ---------- 3. Cuộn để dựng ----------
const steps = document.querySelectorAll(".step");
const byStep = (n) => svg.querySelectorAll(`.piece[data-step="${n}"]`);
function highlight(tl, n, at) {
tl.to(steps, { opacity: 0.3, x: 0, duration: 0.2 }, at).to(steps[n], { opacity: 1, x: 8, duration: 0.2 }, at);
}
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 1 } });
highlight(tl, 0, 0);
tl.from(byStep(0), { scale: 0, transformOrigin: "50% 50%", ease: "back.out(1.4)" }, 0);
highlight(tl, 1, 1);
tl.from(byStep(1), { y: 120, opacity: 0 }, 1);
highlight(tl, 2, 2);
// Từng món rơi từ trên xuống, lần lượt.
tl.from(byStep(2), { y: -260, opacity: 0, stagger: 0.35, ease: "bounce.out" }, 2);
highlight(tl, 3, 3.4);
tl.from(byStep(3), { y: -260, opacity: 0, stagger: 0.35, ease: "bounce.out" }, 3.4);
highlight(tl, 4, 4.8);
// Buổi tối: scene giảm sáng, nền section tối theo. Không dùng lớp phủ nên không lộ viền khung SVG.
const DUSK = 0.45;
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim();
const duskBg = gsap.utils.interpolate(bg, "#1d1a2b", DUSK);
tl.fromTo(scene, { filter: "brightness(1)" }, { filter: `brightness(${1 - DUSK})` }, 4.8).to(".build", { backgroundColor: duskBg }, 4.8).to(glow, { opacity: 1 }, 5.1);
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
tl.progress(1);
} else {
ScrollTrigger.create({
trigger: ".build",
start: "top top",
end: "+=2600",
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.