bodev.vn

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

Tiếp trong Nghệ thuậtPhòng trưng bày 3D