bodev.vn

Phòng trưng bày 3D

Cuộn là đi dọc bức tường tranh trong không gian 3D. Tranh tới giữa thì sáng đèn, hiện bảng tên.

Cách hoạt động

  1. 1.Bức tường nghiêng bằng CSS 3D: perspective ở khung ngoài, rotateY(-24deg) ở .wall-plane. Phía phải lùi ra xa như đang nhìn dọc tường.
  2. 2.Dải tranh .wall-track nằm bên trong mặt tường nghiêng, nên dịch x là trượt dọc theo tường chứ không trượt ngang màn hình.
  3. 3.Mỗi frame, JS đo tranh nào gần giữa màn hình để bật đèn rọi, tăng sáng và hiện bảng tên. quickSetter giúp việc này không tốn.

Thử chỉnh

Thêm tác phẩm
Copy một khối figure.work, đổi ảnh và chú thích. Quãng cuộn tự tính lại.
Tường nghiêng nhiều hơn
Tăng góc rotateY trong .wall-plane, ví dụ -32deg.
Tranh to hơn
Sửa height: 44vh của .work img.

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>Phòng trưng bày 3D · 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 {
  --wall: #f2efe9;
  --floor: #cfc6b8;
  --ink: #22201c;
  --muted: #7b756b;
  --accent: #a4462f;
}

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Be Vietnam Pro", system-ui, sans-serif;
  background: var(--wall);
  color: var(--ink);
  overflow-x: hidden;
}

.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); }

.gallery {
  position: relative;
  height: 100vh;
  overflow: hidden;
  /* tường trên, sàn gỗ dưới */
  background: linear-gradient(to bottom, var(--wall) 0 74%, #b9ae9d 74%, var(--floor) 100%);
}
.scene {
  position: absolute;
  inset: 0;
  perspective: 1100px;
  perspective-origin: 50% 45%;
}
/* Mặt tường nghiêng: phía phải lùi ra xa, như đang đứng nhìn dọc bức tường. */
.wall-plane {
  position: absolute;
  top: 12vh;
  left: 0;
  height: 60vh;
  transform: rotateY(-24deg);
  transform-origin: 0 50%;
  transform-style: preserve-3d;
}
.wall-track {
  display: flex;
  align-items: center;
  gap: 14vw;
  height: 100%;
  padding: 0 60vw 0 40vw;
  will-change: transform;
}

.work { position: relative; flex: none; margin: 0; }
.work img {
  display: block;
  height: 44vh;
  width: auto;
  border: 10px solid #1d1b18; /* khung tranh */
  box-shadow: 0 30px 40px -24px rgba(0, 0, 0, 0.55);
}
/* Quầng đèn rọi từ trần, JS bật theo độ gần giữa màn hình. */
.light {
  position: absolute;
  left: 50%;
  top: -18vh;
  width: 170%;
  height: 90vh;
  translate: -50% 0;
  background: radial-gradient(ellipse at 50% 20%, rgba(255, 236, 196, 0.85), rgba(255, 236, 196, 0) 60%);
  pointer-events: none;
  opacity: 0;
  z-index: -1;
}
figcaption {
  position: absolute;
  top: calc(100% + 22px);
  left: 0;
  width: max(100%, 220px);
  font-size: 13px;
  color: var(--muted);
  opacity: 0;
}
figcaption b { display: block; margin-bottom: 4px; font-size: 17px; color: var(--ink); }

.progress { position: absolute; left: clamp(20px, 5vw, 64px); right: clamp(20px, 5vw, 64px); bottom: 24px; height: 2px; margin: 0; background: rgba(0, 0, 0, 0.12); }
.progress-bar { display: block; height: 100%; background: var(--accent); transform: scaleX(0); transform-origin: 0 50%; }

.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;
  padding: 14px 26px;
  border-radius: 999px;
  background: var(--ink);
  color: #fff;
  font-weight: 700;
}
.note { margin: 20px 0 0; font-size: 13px; color: var(--muted); }

@media (max-width: 760px) {
  .wall-plane { transform: rotateY(-16deg); top: 16vh; }
  .work img { height: 34vh; }
  .wall-track { gap: 22vw; padding-left: 30vw; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Nghệ thuật · Triển lãm online</p>
  <h1>Đêm, sông và phố</h1>
  <p class="lead">Cuộn xuống để đi dọc bức tường tranh.</p>
</section>

<section class="gallery">
  <div class="scene">
    <!-- .wall-plane nghiêng cố định trong không gian 3D. .wall-track trượt dọc theo mặt tường. -->
    <div class="wall-plane">
      <div class="wall-track">
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/dam-sen-son-mai-nho.webp" alt="Đầm sen đêm, sơn mài" width="1200" height="806" />
          <figcaption><b>Đầm sen đêm</b>Sơn mài trên vóc · 120 × 80 cm · 2025</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/thieu-nu-ben-cua.webp" alt="Thiếu nữ ngồi bên cửa sổ, tranh lụa" width="896" height="1200" />
          <figcaption><b>Thiếu nữ bên cửa</b>Lụa · 60 × 80 cm · 2024</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/mai-pho-co.webp" alt="Mái ngói phố cổ lúc chiều tối, sơn dầu" width="1200" height="896" />
          <figcaption><b>Mái phố cổ</b>Sơn dầu · 100 × 75 cm · 2023</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/dat-va-cham.webp" alt="Tranh trừu tượng màu đất nung và chàm" width="896" height="1200" />
          <figcaption><b>Đất và chàm</b>Acrylic · 90 × 120 cm · 2025</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/ga-trong.webp" alt="Tranh khắc gỗ con gà trống" width="896" height="1200" />
          <figcaption><b>Gà trống</b>Khắc gỗ trên giấy dó · 45 × 60 cm · 2024</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/song-mekong.webp" alt="Thuyền trên sông Mekong trong sương sớm, màu nước" width="1200" height="896" />
          <figcaption><b>Sương Mekong</b>Màu nước · 56 × 42 cm · 2022</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/deo-ha-giang.webp" alt="Núi đá Hà Giang, thủy mặc" width="896" height="1200" />
          <figcaption><b>Đèo Hà Giang</b>Thủy mặc · 50 × 70 cm · 2023</figcaption>
        </figure>
        <figure class="work">
          <span class="light" aria-hidden="true"></span>
          <img src="https://bodev.vn/lab-assets/nghe-thuat/ban-tay-va-luoi.webp" alt="Đôi bàn tay vá lưới, than chì" width="1200" height="896" />
          <figcaption><b>Bàn tay và lưới</b>Than chì · 70 × 50 cm · 2025</figcaption>
        </figure>
      </div>
    </div>
  </div>
  <p class="progress" aria-hidden="true"><span class="progress-bar"></span></p>
</section>

<section class="outro">
  <h2>Muốn xem tận mắt?</h2>
  <button type="button" class="btn">Đặt lịch xem triển lãm</button>
  <p class="note">Tranh trong demo là minh họa tạo bằng AI.</p>
</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>
// Phòng trưng bày 3D — bodev.vn/lab/phong-trung-bay
// Cần: gsap + ScrollTrigger.

gsap.registerPlugin(ScrollTrigger);

const track = document.querySelector(".wall-track");
const works = gsap.utils.toArray(".work");

// Mỗi tranh có sẵn các hàm set nhanh, gọi mỗi frame không tạo tween mới.
const setters = works.map((work) => ({
  work,
  img: gsap.quickSetter(work.querySelector("img"), "filter"),
  light: gsap.quickSetter(work.querySelector(".light"), "opacity"),
  caption: gsap.quickSetter(work.querySelector("figcaption"), "opacity"),
}));

// Tranh càng gần giữa màn hình càng sáng, đèn rọi càng rõ, bảng tên hiện ra.
function spotlight() {
  const mid = window.innerWidth / 2;
  for (const s of setters) {
    const r = s.work.getBoundingClientRect();
    const d = Math.abs(r.left + r.width / 2 - mid) / mid;
    const focus = gsap.utils.clamp(0, 1, 1 - d * 1.4);
    s.img(`brightness(${0.55 + 0.45 * focus})`);
    s.light(focus);
    s.caption(gsap.utils.clamp(0, 1, (focus - 0.55) * 3));
  }
}

// Tường nghiêng 3D nên vị trí trên màn hình không tỉ lệ thẳng với vị trí trên tường.
// Dò vài vòng (phương pháp dây cung) để tìm độ dịch đưa một bức tranh vào đúng giữa màn hình.
function centerOn(work, guess = 0) {
  const mid = window.innerWidth / 2;
  const errorAt = (x) => {
    gsap.set(track, { x });
    const r = work.getBoundingClientRect();
    return r.left + r.width / 2 - mid;
  };
  let x0 = guess;
  let e0 = errorAt(x0);
  let x1 = x0 - e0;
  for (let i = 0; i < 8; i++) {
    const e1 = errorAt(x1);
    if (Math.abs(e1) < 1 || e1 === e0) break;
    [x0, e0, x1] = [x1, e1, x1 - (e1 * (x1 - x0)) / (e1 - e0)];
  }
  return x1;
}

// Đi từ lúc tranh đầu ở giữa tới lúc tranh cuối ở giữa.
let startX = 0;
let endX = 0;
function calibrate() {
  // Dò lần lượt từng bức, lấy kết quả bức trước làm điểm xuất phát.
  // Nhảy thẳng tới bức cuối thì bức đó đang nằm sâu sau điểm nhìn, phép chiếu bị lật, không dò được.
  let x = 0;
  works.forEach((work, i) => {
    x = centerOn(work, x);
    if (i === 0) startX = x;
  });
  endX = x;
  gsap.set(track, { x: startX });
}
calibrate();
ScrollTrigger.addEventListener("refreshInit", calibrate);

const walk = gsap.fromTo(track, { x: () => startX }, { x: () => endX, ease: "none", onUpdate: spotlight });

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Không đi bộ: bỏ nghiêng 3D, cho kéo ngang bằng tay.
  walk.progress(0).kill();
  document.querySelector(".wall-plane").style.transform = "none";
  document.querySelector(".gallery").style.overflowX = "auto";
  gsap.set(".light, figcaption", { opacity: 1 });
} else {
  ScrollTrigger.create({
    trigger: ".gallery",
    start: "top top",
    end: () => "+=" + Math.abs(endX - startX) * 1.1,
    pin: true,
    scrub: 1,
    animation: walk,
    invalidateOnRefresh: true,
    onUpdate: (self) => gsap.set(".progress-bar", { scaleX: self.progress }),
  });
  spotlight();
}

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 Nghệ thuậtĐi sâu vào bức tranh