Mặt cắt thông gió nhà ống
Mặt cắt nhà phố tự vẽ, nắng rọi qua giếng trời, gió chạy xuyên các tầng rồi thoát lên mái.
Cách hoạt động
- 1.Mũi tên gió chạy theo đường cong bằng MotionPathPlugin (autoRotate để mũi tên quay theo hướng gió). Mỗi đường có 4 mũi tên rải đều.
- 2.Gió là vòng lặp chạy tự do, không gắn vào thanh cuộn: cuộn tới bước 3 thì bật, cuộn ngược lên hoặc rời section thì tắt.
- 3.Các bước còn lại (vẽ mặt cắt, nắng, khí nóng, nhiệt độ) nằm trên một timeline scrub như bình thường.
- 4.Đường gió ẩn trong SVG chỉ làm quỹ đạo. Vẽ lại đường là gió đổi hướng, không phải sửa JS.
Thử chỉnh
- Gió nhanh, chậm hơn
- Sửa WIND_SECONDS (số giây chạy hết một đường).
- Nhiều mũi tên hơn
- Tăng ARROWS_PER_PATH.
- Thêm luồng gió
- Thêm một path trong .winds. JS tự rải mũi tên lên đó.
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ặt cắt thông gió nhà ố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 {
--paper: #f3f1eb;
--ink: #22333b;
--muted: #6c7a7f;
--wind: #2a9d8f;
--sun: #f4a261;
--hot: #e76f51;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--paper);
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(--wind); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { max-width: 14ch; 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); }
.section-view {
height: 100vh;
display: grid;
grid-template-columns: minmax(180px, 260px) 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: 17px; font-weight: 500; opacity: 0.3; }
.step b { display: inline-block; width: 2.4em; color: var(--wind); }
.cut { width: 100%; max-height: 88vh; overflow: visible; }
.drawing .ln { fill: none; stroke: var(--ink); stroke-width: 2; stroke-linecap: round; }
.drawing .bold { stroke-width: 4; }
.drawing .thin { stroke-width: 1.2; }
.sun { fill: var(--sun); }
.rays line { stroke: var(--sun); stroke-width: 3; stroke-dasharray: 6 8; opacity: 0.8; }
.light ellipse { fill: #ffd27a; opacity: 0.75; }
.heat { fill: url(#warm); }
.winds path { fill: none; stroke: var(--wind); stroke-width: 1.5; stroke-dasharray: 3 7; opacity: 0.35; }
.arrow { fill: var(--wind); }
.temp rect { fill: #fff; stroke: rgba(34, 51, 59, 0.15); }
.temp text { fill: var(--muted); font-size: 13px; }
.temp .deg { fill: var(--ink); font-size: 20px; font-weight: 800; }
.temp.out .deg { fill: var(--hot); }
.temp.in .deg { fill: var(--wind); }
.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) {
.section-view { 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">Kiến trúc · Nhà ống 5×20m</p>
<h1>Nhà mát mà không cần máy lạnh</h1>
<p class="lead">Cuộn xuống để xem nắng và gió đi qua ngôi nhà.</p>
</section>
<section class="section-view">
<ol class="steps">
<li class="step"><b>01</b> Mặt cắt dọc nhà</li>
<li class="step"><b>02</b> Giếng trời lấy sáng</li>
<li class="step"><b>03</b> Gió xuyên phòng</li>
<li class="step"><b>04</b> Khí nóng thoát mái</li>
<li class="step"><b>05</b> Trong mát hơn ngoài</li>
</ol>
<svg class="cut" viewBox="0 0 1000 640" role="img" aria-label="Mặt cắt nhà ống với giếng trời và luồng gió">
<defs>
<linearGradient id="warm" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#f4a261" stop-opacity="0" />
<stop offset="1" stop-color="#e76f51" stop-opacity="0.45" />
</linearGradient>
</defs>
<!-- Mặt trời và tia nắng: tia phải lọt qua ô kính giếng trời (x 450–560 ở cao độ mái), không xuyên mái bê tông -->
<circle class="sun" cx="566" cy="30" r="22" />
<g class="rays">
<line x1="548" y1="58" x2="470" y2="540" />
<line x1="556" y1="60" x2="500" y2="540" />
<line x1="562" y1="58" x2="528" y2="540" />
</g>
<!-- Vệt sáng rơi xuống các sàn -->
<g class="light">
<ellipse cx="505" cy="556" rx="70" ry="7" />
<ellipse cx="428" cy="436" rx="36" ry="5" />
<ellipse cx="584" cy="316" rx="36" ry="5" />
</g>
<!-- Khí nóng bốc lên trong giếng trời -->
<rect class="heat" x="452" y="110" width="106" height="450" />
<!-- Bản vẽ: nét nào có class="ln" sẽ tự vẽ ra -->
<g class="drawing">
<line class="ln bold" x1="30" y1="560" x2="970" y2="560" />
<path class="ln bold" d="M120 560 V110 H450 M560 110 H880 V560" />
<path class="ln" d="M120 440 H450 M560 440 H880 M120 320 H450 M560 320 H880 M120 200 H450 M560 200 H880" />
<!-- mái kính giếng trời, nâng cao để gió thoát -->
<path class="ln" d="M440 110 V84 H570 V110" />
<path class="ln thin" d="M452 96 H558" />
<!-- cửa mặt tiền và cửa sau -->
<path class="ln thin" d="M120 480 V540 M120 360 V420 M120 240 V300 M880 480 V540 M880 360 V420" />
<!-- cầu thang -->
<path class="ln thin" d="M720 560 L800 440 M720 440 L800 320 M720 320 L800 200" />
<!-- cây trong giếng trời và cây trước nhà -->
<path class="ln" d="M505 560 V510" />
<circle class="ln" cx="505" cy="495" r="18" />
<path class="ln" d="M70 560 V480" />
<circle class="ln" cx="70" cy="455" r="30" />
</g>
<!-- Đường gió: mũi tên chạy theo các đường này (ẩn, chỉ làm quỹ đạo) -->
<g class="winds">
<path id="wind-1" d="M20 510 C 200 505, 380 520, 505 520 C 520 400, 500 250, 505 100 C 510 60, 560 40, 640 30" />
<path id="wind-2" d="M20 395 C 220 390, 380 395, 490 380 C 500 280, 500 180, 505 100 C 515 60, 580 45, 660 40" />
<path id="wind-3" d="M20 275 C 220 270, 400 270, 495 260 C 505 200, 505 150, 505 100 C 520 70, 590 60, 680 55" />
</g>
<g class="arrows"></g>
<g class="temps">
<g class="temp out">
<rect x="24" y="16" width="170" height="54" rx="12" />
<text x="40" y="40">Ngoài trời</text>
<text class="deg" x="40" y="62"><tspan data-temp="36">36</tspan>°C</text>
</g>
<g class="temp in">
<rect x="200" y="470" width="170" height="54" rx="12" />
<text x="216" y="494">Trong nhà</text>
<text class="deg" x="216" y="516"><tspan data-temp="29">29</tspan>°C</text>
</g>
</g>
</svg>
</section>
<section class="outro">
<h2>Thiết kế theo khí hậu, không theo máy lạnh.</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 src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js"></script>
<script>
// Mặt cắt thông gió nhà ống — bodev.vn/lab/mat-cat-thong-gio
// Cần: gsap + ScrollTrigger + MotionPathPlugin.
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
const NS = "http://www.w3.org/2000/svg";
const steps = document.querySelectorAll(".step");
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- 1. Mũi tên gió chạy vòng theo MotionPath ----------
// Mũi tên là vòng lặp chạy tự do (không gắn vào thanh cuộn), vì gió phải chạy liên tục khi đang xem.
const ARROWS_PER_PATH = 4;
const WIND_SECONDS = 4.5;
const arrowsBox = document.querySelector(".arrows");
const windLoops = [];
document.querySelectorAll(".winds path").forEach((path, p) => {
for (let i = 0; i < ARROWS_PER_PATH; i++) {
const arrow = document.createElementNS(NS, "path");
arrow.setAttribute("class", "arrow");
arrow.setAttribute("d", "M-7 -5 L7 0 L-7 5 Z");
arrowsBox.appendChild(arrow);
const loop = gsap.to(arrow, {
motionPath: { path, align: path, alignOrigin: [0.5, 0.5], autoRotate: true },
duration: WIND_SECONDS,
ease: "none",
repeat: -1,
paused: true,
});
// Rải đều các mũi tên trên đường, đường sau lệch nhịp với đường trước.
loop.progress((i / ARROWS_PER_PATH + p * 0.13) % 1);
windLoops.push(loop);
}
});
gsap.set(".arrows", { opacity: 0 });
let windOn = false;
function setWind(on) {
if (on === windOn) return;
windOn = on;
windLoops.forEach((l) => (on ? l.play() : l.pause()));
gsap.to(".arrows", { opacity: on ? 1 : 0, duration: 0.4 });
}
// ---------- 2. Bản vẽ tự vẽ: stroke-dashoffset từ độ dài nét về 0 ----------
const lines = [...document.querySelectorAll(".drawing .ln")];
lines.forEach((el) => {
const len = el.getTotalLength();
el.style.strokeDasharray = `${len}`;
el.style.strokeDashoffset = `${len}`;
});
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);
}
// ---------- 3. Timeline theo cuộn ----------
const WIND_AT = 2.6; // thời điểm trên timeline gió bắt đầu thổi
const tl = gsap.timeline({ defaults: { ease: "power2.out" } });
highlight(tl, 0, 0);
tl.to(lines, { strokeDashoffset: 0, duration: 1, stagger: 0.05, ease: "power1.inOut" }, 0);
highlight(tl, 1, 1.3);
tl.from(".sun", { y: -80, opacity: 0, duration: 0.6 }, 1.3)
.from(".rays line", { scaleY: 0, transformOrigin: "50% 0%", opacity: 0, duration: 0.7, stagger: 0.1 }, 1.5)
.from(".light ellipse", { scale: 0, transformOrigin: "50% 50%", opacity: 0, duration: 0.5, stagger: 0.1 }, 1.9);
highlight(tl, 2, WIND_AT);
tl.from(".winds path", { opacity: 0, duration: 0.5, stagger: 0.1 }, WIND_AT);
highlight(tl, 3, 3.6);
tl.from(".heat", { scaleY: 0, transformOrigin: "50% 100%", opacity: 0, duration: 0.8 }, 3.6);
highlight(tl, 4, 4.6);
tl.from(".temp", { opacity: 0, y: 12, duration: 0.4, stagger: 0.2 }, 4.6);
// Nhiệt độ đếm từ 20 lên con số thật.
document.querySelectorAll("[data-temp]").forEach((node) => {
const target = Number(node.dataset.temp);
const counter = { v: 20 };
tl.to(counter, { v: target, duration: 0.8, onUpdate: () => (node.textContent = Math.round(counter.v)) }, 4.7);
});
if (reduceMotion) {
tl.progress(1);
// Không chạy vòng lặp: để mũi tên đứng yên giữa đường cho thấy hướng gió.
windLoops.forEach((l) => l.progress(0.5));
gsap.set(".arrows", { opacity: 1 });
} else {
ScrollTrigger.create({
trigger: ".section-view",
start: "top top",
end: "+=2800",
pin: true,
scrub: 1,
animation: tl,
// Gió thổi khi đã tới bước 3, tắt khi cuộn ngược lên hoặc rời khỏi section.
onUpdate: (self) => setWind(self.isActive && self.progress * tl.duration() >= WIND_AT),
onToggle: (self) => !self.isActive && setWind(false),
});
}
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.