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