Đi xuyên cửa
Cuộn là bước tới: zoom qua khung cửa để sang phòng tiếp theo. Phòng khách, bếp, phòng ngủ.
Cách hoạt động
- 1.Các phòng chồng lên nhau. Phòng sau bị cắt (clip-path) đúng bằng khung cửa của phòng trước.
- 2.Một biến s điều khiển cả hai: phòng trước phóng to s lần quanh tâm cửa, phòng sau lộ ra đúng khung cửa đã phóng to. Mép luôn khít, không hở.
- 3.s chạy tới lúc khung cửa phủ kín màn hình thì dừng. Con số này tự tính theo kích thước màn hình.
- 4.Cửa các phòng đặt lệch nhau, nên đứng ở phòng này nhìn qua cửa thấy được đồ đạc phòng bên kia. Tới phòng mới thì cửa kế tiếp mở dần từ giữa ra.
Thử chỉnh
- Dùng ảnh phòng của bạn
- Xem bản "Ảnh thật". Đổi src ảnh, data-size = kích thước ảnh gốc, data-door = x y rộng cao của ô cửa (đo bằng Photoshop hoặc Figma).
- Chụp ảnh sao cho dễ dùng
- Đứng chính diện bức tường có cửa, máy ngang tầm mắt. Cửa càng thẳng mặt thì lúc zoom càng khít.
- Thêm phòng
- Copy một khối .room và một dòng trong .labels. Phòng nào có phòng sau thì phải có .door.
- Bước qua cửa nhanh, chậm khác đi
- Đổi ease: "power2.in" trong timeline, ví dụ "power1.inOut" cho đều hơ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>Đi xuyên cửa · 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: #1b1714;
--text: #f3ede4;
--muted: #a39a8f;
--accent: #e0a458;
}
* { 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 { margin: 10px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }
.tour {
position: relative;
height: 100vh;
overflow: hidden;
}
/* Các phòng chồng lên nhau. Phòng sau nằm trên, bị cắt vừa khung cửa của phòng trước. */
.room { position: absolute; inset: 0; will-change: transform, clip-path; }
.room svg,
.room img { display: block; width: 100%; height: 100%; }
/* Ảnh phủ kín khung, giống preserveAspectRatio="xMidYMid slice" của SVG. */
.room img { object-fit: cover; }
.labels {
position: absolute;
left: clamp(20px, 5vw, 56px);
bottom: clamp(20px, 5vw, 48px);
margin: 0;
padding: 0;
list-style: none;
}
.labels li {
position: absolute;
bottom: 0;
left: 0;
white-space: nowrap;
font-size: clamp(28px, 4.5vw, 56px);
font-weight: 800;
color: #fff;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.35);
opacity: 0;
}
.labels span { font-size: 0.4em; vertical-align: super; color: var(--accent); }
.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: var(--bg);
font-weight: 700;
text-decoration: none;
}
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Nội thất · Căn hộ 2 phòng ngủ</p>
<h1>Đi một vòng căn hộ</h1>
<p class="lead">Cuộn xuống để bước qua từng phòng.</p>
</section>
<section class="tour">
<!--
Mỗi .room là một ảnh phòng.
data-size: kích thước ảnh gốc (rộng cao). Ảnh đã nén nhỏ hơn vẫn giữ số này, miễn cùng tỉ lệ.
data-door: khung cửa trong ảnh gốc, "x y rộng cao" tính bằng pixel. Mở ảnh bằng Photoshop/Figma để đo.
Phòng cuối không cần data-door.
-->
<div class="room" data-size="2752 1536" data-door="1705 387 307 802">
<img src="https://bodev.vn/lab-assets/di-xuyen-cua/phong-khach.webp" alt="Phòng khách" width="1920" height="1072" />
</div>
<div class="room" data-size="2752 1536" data-door="198 237 385 1122">
<img src="https://bodev.vn/lab-assets/di-xuyen-cua/bep.webp" alt="Bếp" width="1920" height="1072" />
</div>
<div class="room" data-size="2752 1536">
<img src="https://bodev.vn/lab-assets/di-xuyen-cua/phong-ngu.webp" alt="Phòng ngủ" width="1920" height="1072" />
</div>
<ol class="labels" aria-live="polite">
<li><span>01</span> Phòng khách</li>
<li><span>02</span> Bếp</li>
<li><span>03</span> Phòng ngủ</li>
</ol>
</section>
<section class="outro">
<h2>Muốn đi một vòng căn hộ thật?</h2>
<button type="button" class="btn">Đặt lịch xem nhà mẫu</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>
// Đi xuyên cửa — bodev.vn/lab/di-xuyen-cua
// Cần: gsap + ScrollTrigger.
gsap.registerPlugin(ScrollTrigger);
const tour = document.querySelector(".tour");
const rooms = gsap.utils.toArray(".room");
const labels = gsap.utils.toArray(".labels li");
// Mỗi .room khai báo:
// data-size="rộng cao" khung toạ độ của ảnh (kích thước ảnh gốc, hoặc viewBox của SVG)
// data-door="x y rộng cao" khung cửa trong toạ độ đó (phòng cuối không cần)
const nums = (value) => value.trim().split(/[\s,]+/).map(Number);
// Vị trí khung cửa trên màn hình (chưa zoom), tính bằng toán, không đo DOM.
// Ảnh phủ kín khung (object-fit: cover / xMidYMid slice) nên tỉ lệ = max.
function door(room) {
const [vw, vh] = nums(room.dataset.size);
const [dx, dy, dw, dh] = nums(room.dataset.door);
const W = tour.clientWidth;
const H = tour.clientHeight;
const s = Math.max(W / vw, H / vh);
const x = (W - vw * s) / 2 + dx * s;
const y = (H - vh * s) / 2 + dy * s;
const w = dw * s;
const h = dh * s;
return { x, y, w, h, cx: x + w / 2, cy: y + h / 2, W, H };
}
// Phóng to bao nhiêu lần thì khung cửa phủ kín màn hình.
function coverScale(d) {
return 1.02 * Math.max((2 * d.cx) / d.w, (2 * (d.W - d.cx)) / d.w, (2 * d.cy) / d.h, (2 * (d.H - d.cy)) / d.h);
}
// Vùng lộ ra của phòng sau = đúng khung cửa của phòng trước, đã phóng to s lần quanh tâm cửa.
// open (0 → 1): cửa mở từ giữa ra hai bên.
function clipAt(d, s, open = 1) {
const halfW = (d.w / 2) * s * open;
const left = d.cx - halfW;
const top = d.cy - (d.h / 2) * s;
const right = d.W - (d.cx + halfW);
const bottom = d.H - (d.cy + (d.h / 2) * s);
const px = (n) => `${Math.max(0, n).toFixed(1)}px`;
return `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)})`;
}
const HIDDEN = "inset(50% 50% 50% 50%)";
const STEP = 1; // thời lượng bước qua một cửa
const PAUSE = 0.4; // dừng ngắm mỗi phòng
const OPEN = 0.25; // thời gian cửa mở khi vừa tới phòng
const openEase = gsap.parseEase("power2.out");
const starts = rooms.slice(0, -1).map((_, i) => i * (STEP + PAUSE));
const ease = gsap.parseEase("power2.in"); // chậm lúc đầu, nhanh dần khi bước qua cửa
// Vẽ toàn cảnh chỉ từ thời điểm t. Cuộn tới, cuộn lui, tua giữa chừng đều ra đúng một hình.
function render(t) {
rooms.slice(0, -1).forEach((room, i) => {
const next = rooms[i + 1];
const d = door(room);
const progress = gsap.utils.clamp(0, 1, (t - starts[i]) / STEP);
const s = 1 + (coverScale(d) - 1) * ease(progress);
gsap.set(room, { scale: s, transformOrigin: `${d.cx}px ${d.cy}px` });
// Phòng kế tiếp chỉ lộ ra khi đã đứng trong phòng này, rồi cửa mở dần.
const arrived = starts[i] - PAUSE;
const open = i === 0 ? 1 : gsap.utils.clamp(0, 1, (t - arrived) / OPEN);
gsap.set(next, { clipPath: open > 0 ? clipAt(d, s, openEase(open)) : HIDDEN });
});
}
const clock = { t: 0 };
const total = starts[starts.length - 1] + STEP;
const tl = gsap.timeline();
tl.to(clock, { t: total, duration: total, ease: "none", onUpdate: () => render(clock.t) }, 0);
starts.forEach((at, i) => {
tl.to(labels[i], { opacity: 0, y: -20, duration: 0.3 }, at).fromTo(
labels[i + 1],
{ opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: 0.3 },
at + 0.7,
);
});
gsap.set(labels[0], { opacity: 1 });
render(0);
// Đổi kích thước màn hình thì vẽ lại theo toạ độ mới.
ScrollTrigger.addEventListener("refresh", () => render(clock.t));
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
// Không zoom: hiện phòng cuối, tour vẫn đọc được qua nhãn.
tl.progress(1);
} else {
ScrollTrigger.create({
trigger: tour,
start: "top top",
end: () => "+=" + window.innerHeight * rooms.length,
pin: true,
scrub: 1,
animation: tl,
invalidateOnRefresh: true,
});
}
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.