Bức tường vô tận
Kéo theo mọi hướng, tường tranh lặp mãi không hết, thả tay còn trôi. Bấm vào tranh để phóng to bằng Flip.
Cách hoạt động
- 1.Tường chỉ có 24 ô. Khi một ô trôi khỏi mép này, gsap.utils.wrap đưa nó sang mép đối diện, nên kéo mãi không hết.
- 2.Draggable kéo một phần tử vô hình (proxy). Toạ độ proxy là độ lệch của cả tường. InertiaPlugin cho tường trôi tiếp sau khi thả tay.
- 3.Bấm vào tranh: Flip ghi vị trí ảnh, chuyển ảnh vào lightbox, rồi cho ảnh bay từ chỗ cũ tới chỗ mới.
- 4.Chưa ai chạm thì tường tự trôi chậm để gợi ý là kéo được. Rê chuột vào là dừng, để bấm tranh không phải đuổi theo.
Thử chỉnh
- Thêm tác phẩm
- Thêm figure.tile trong .tiles. JS tự nhân bản cho đủ ô.
- Ô to, nhỏ hơn
- Sửa --cell-w trong CSS. Ô nhỏ hơn thì có thể phải tăng COLS, ROWS để phủ kín màn hình.
- Tắt tự trôi
- Đặt autoPan = false ngay từ đầu trong file JS.
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>Bức tường vô tận · 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: #efece6;
--ink: #1f1d1a;
--muted: #7b756b;
--accent: #a4462f;
/* Kích thước một ô trên tường. JS đọc hai biến này. */
--cell-w: max(240px, 22vw);
--cell-h: calc(var(--cell-w) * 1.25);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
}
.intro,
.outro {
min-height: 60vh;
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 { 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); }
.wall {
position: relative;
height: 100vh;
overflow: hidden;
cursor: grab;
touch-action: pan-y; /* điện thoại: vuốt dọc vẫn cuộn trang, kéo ngang là kéo tường */
user-select: none;
}
.wall.is-dragging { cursor: grabbing; }
.tiles { position: absolute; inset: 0; }
.tile {
position: absolute;
left: 0;
top: 0;
width: var(--cell-w);
height: var(--cell-h);
margin: 0;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: center;
will-change: transform;
}
/* Ảnh ôm theo tỉ lệ thật, chú thích nằm ngay dưới ảnh chứ không dính đáy ô. */
.tile img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: calc(100% - 44px);
margin: 0 auto;
cursor: zoom-in;
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.28));
transition: scale 0.3s;
}
.tile:hover img { scale: 1.04; }
.tile figcaption { margin-top: 10px; font-size: 12px; color: var(--muted); text-align: center; }
.tile figcaption b { display: block; font-size: 14px; color: var(--ink); }
.hint {
position: absolute;
left: 50%;
bottom: 22px;
translate: -50% 0;
margin: 0;
padding: 8px 16px;
border-radius: 999px;
background: var(--ink);
color: #fff;
font-size: 13px;
pointer-events: none;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
grid-template-rows: 1fr auto;
padding: 5vh 5vw 3vh;
background: rgba(20, 19, 17, 0.9);
}
.lightbox[hidden] { display: none; }
.lightbox-frame { display: grid; place-items: center; width: 100%; height: 100%; min-height: 0; }
.lightbox-frame img { max-width: 100%; max-height: 80vh; width: auto; height: auto; object-fit: contain; }
.lightbox-caption { margin: 14px 0 0; color: #f1ece2; font-size: 15px; text-align: center; }
.close {
position: absolute;
top: 16px;
right: 16px;
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
background: #f1ece2;
color: var(--ink);
font-size: 18px;
cursor: pointer;
}
.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); }
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Nghệ thuật · Bộ sưu tập</p>
<h1>Bức tường không có điểm cuối</h1>
<p class="lead">Kéo theo hướng nào cũng được.</p>
</section>
<section class="wall" aria-label="Tường tranh, kéo để xem">
<!-- Danh sách tác phẩm. JS nhân bản để phủ kín tường và cho quay vòng vô tận. -->
<div class="tiles">
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/dam-sen-son-mai-nho.webp" alt="Đầm sen đêm" width="1200" height="806" draggable="false" /><figcaption><b>Đầm sen đêm</b>Sơn mài · 2025</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/thieu-nu-ben-cua.webp" alt="Thiếu nữ bên cửa" width="896" height="1200" draggable="false" /><figcaption><b>Thiếu nữ bên cửa</b>Lụa · 2024</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/mai-pho-co.webp" alt="Mái phố cổ" width="1200" height="896" draggable="false" /><figcaption><b>Mái phố cổ</b>Sơn dầu · 2023</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/dat-va-cham.webp" alt="Đất và chàm" width="896" height="1200" draggable="false" /><figcaption><b>Đất và chàm</b>Acrylic · 2025</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/ga-trong.webp" alt="Gà trống" width="896" height="1200" draggable="false" /><figcaption><b>Gà trống</b>Khắc gỗ · 2024</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/song-mekong.webp" alt="Sương Mekong" width="1200" height="896" draggable="false" /><figcaption><b>Sương Mekong</b>Màu nước · 2022</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/deo-ha-giang.webp" alt="Đèo Hà Giang" width="896" height="1200" draggable="false" /><figcaption><b>Đèo Hà Giang</b>Thủy mặc · 2023</figcaption></figure>
<figure class="tile"><img src="https://bodev.vn/lab-assets/nghe-thuat/ban-tay-va-luoi.webp" alt="Bàn tay và lưới" width="1200" height="896" draggable="false" /><figcaption><b>Bàn tay và lưới</b>Than chì · 2025</figcaption></figure>
</div>
<p class="hint">Kéo để xem · Bấm để phóng to</p>
</section>
<div class="lightbox" role="dialog" aria-modal="true" aria-label="Xem tranh" hidden>
<div class="lightbox-frame"></div>
<p class="lightbox-caption"></p>
<button type="button" class="close" aria-label="Đóng">✕</button>
</div>
<section class="outro">
<h2>Thích bức nào?</h2>
<button type="button" class="btn">Hỏi giá tác phẩ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/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/InertiaPlugin.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Flip.min.js"></script>
<script>
// Bức tường vô tận — bodev.vn/lab/tuong-tranh-vo-tan
// Cần: gsap + Draggable + InertiaPlugin + Flip. Tất cả miễn phí.
gsap.registerPlugin(Draggable, InertiaPlugin, Flip);
const wall = document.querySelector(".wall");
const tilesBox = document.querySelector(".tiles");
const originals = [...tilesBox.children];
// Số ô trên tường. 6 × 4 đủ phủ kín màn hình rộng tới 2560px với cỡ ô trong CSS.
const COLS = 6;
const ROWS = 4;
// ---------- 1. Nhân bản tác phẩm cho đủ COLS × ROWS ô ----------
tilesBox.textContent = "";
const cells = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
// Lệch thứ tự mỗi hàng để hai bức giống nhau không đứng cạnh nhau.
const tile = originals[(c + r * 3) % originals.length].cloneNode(true);
tilesBox.appendChild(tile);
cells.push({ tile, c, r });
}
}
let cellW = 0;
let cellH = 0;
function measure() {
cellW = cells[0].tile.offsetWidth;
cellH = cells[0].tile.offsetHeight;
}
measure();
// ---------- 2. Quay vòng: ô trôi ra khỏi mép này thì xuất hiện ở mép kia ----------
const offset = { x: 0, y: 0 };
const setters = cells.map(({ tile }) => ({ x: gsap.quickSetter(tile, "x", "px"), y: gsap.quickSetter(tile, "y", "px") }));
function layout() {
const wrapX = gsap.utils.wrap(-cellW, COLS * cellW - cellW);
const wrapY = gsap.utils.wrap(-cellH, ROWS * cellH - cellH);
cells.forEach(({ c, r }, i) => {
// Cột lẻ thấp hơn nửa ô: nhìn như tường treo tranh, không phải lưới bảng tính.
setters[i].x(wrapX(c * cellW + offset.x));
setters[i].y(wrapY(r * cellH + (c % 2) * cellH * 0.5 + offset.y));
});
}
layout();
window.addEventListener("resize", () => {
measure();
layout();
});
// ---------- 3. Kéo + quán tính ----------
// Kéo một phần tử "proxy" vô hình, đọc toạ độ của nó để dịch cả tường.
const proxy = document.createElement("div");
let autoPan = true; // tắt hẳn sau lần kéo đầu tiên
let hovering = false; // rê chuột vào tường thì dừng trôi, để bấm vào tranh không phải đuổi theo
const [drag] = Draggable.create(proxy, {
trigger: wall,
type: "x,y",
inertia: true,
allowNativeTouchScrolling: true,
onPress: () => (autoPan = false),
onDragStart: () => wall.classList.add("is-dragging"),
onDragEnd: () => wall.classList.remove("is-dragging"),
onDrag: sync,
onThrowUpdate: sync,
// Bấm (không kéo) vào tranh thì phóng to.
onClick(e) {
const tile = e.target.closest(".tile");
if (tile) openLightbox(tile);
},
});
function sync() {
offset.x = drag.x;
offset.y = drag.y;
layout();
}
// Chưa ai chạm thì tường tự trôi chậm, gợi ý là kéo được.
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
gsap.ticker.add(() => {
if (!autoPan || hovering) return;
offset.x -= 0.35;
offset.y -= 0.15;
gsap.set(proxy, { x: offset.x, y: offset.y }); // để lúc bắt đầu kéo, tường không giật về chỗ cũ
layout();
});
}
wall.addEventListener("pointerenter", () => (hovering = true));
wall.addEventListener("pointerleave", () => (hovering = false));
// ---------- 4. Phóng to bằng Flip ----------
const lightbox = document.querySelector(".lightbox");
const frame = lightbox.querySelector(".lightbox-frame");
const caption = lightbox.querySelector(".lightbox-caption");
let opened = null;
function openLightbox(tile) {
const img = tile.querySelector("img");
const state = Flip.getState(img);
opened = { tile, img };
const fig = tile.querySelector("figcaption");
const title = fig.querySelector("b").textContent;
caption.textContent = `${title} · ${fig.textContent.replace(title, "").trim()}`;
lightbox.hidden = false;
frame.appendChild(img); // chuyển ảnh sang lightbox, Flip lo phần bay
Flip.from(state, { duration: reduceMotion ? 0 : 0.6, ease: "power3.inOut", scale: true });
gsap.fromTo(lightbox, { backgroundColor: "rgba(20,19,17,0)" }, { backgroundColor: "rgba(20,19,17,0.9)", duration: 0.4 });
lightbox.querySelector(".close").focus();
}
function closeLightbox() {
if (!opened) return;
const { tile, img } = opened;
const state = Flip.getState(img);
tile.insertBefore(img, tile.firstChild);
Flip.from(state, { duration: reduceMotion ? 0 : 0.5, ease: "power3.inOut", scale: true });
gsap.to(lightbox, {
backgroundColor: "rgba(20,19,17,0)",
duration: 0.3,
onComplete: () => (lightbox.hidden = true),
});
opened = null;
}
lightbox.querySelector(".close").addEventListener("click", closeLightbox);
lightbox.addEventListener("click", (e) => e.target === lightbox && closeLightbox());
document.addEventListener("keydown", (e) => e.key === "Escape" && closeLightbox());
// Tường hiện ra khi cuộn tới.
gsap.from(".tile", {
opacity: 0,
scale: 0.85,
duration: 0.8,
stagger: { each: 0.02, from: "center" },
ease: "power3.out",
delay: 0.2,
});
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.