bodev.vn

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

Tiếp trong Nội thấtPhối màu bằng vòng xoay