bodev.vn

Đi sâu vào bức tranh

Cuộn là zoom lần lượt vào từng chi tiết của bức sơn mài, kèm chú thích. Bản đồ nhỏ cho biết đang xem chỗ nào.

Cách hoạt động

  1. 1.Mỗi điểm dừng là { x, y, zoom } theo tỉ lệ 0–1 trên tranh. Muốn đưa điểm đó về giữa màn hình: scale = zoom, dịch (0.5 − x) × rộng × zoom.
  2. 2.Khung đỏ trên bản đồ nhỏ rộng 1/zoom, tâm tại (x, y), tính bằng phần trăm nên không cần đo DOM.
  3. 3.Giữa hai điểm có quãng dừng để đọc chú thích trước khi zoom tiếp.

Thử chỉnh

Dùng tranh của bạn
Đổi hai ảnh (ảnh lớn để zoom, ảnh nhỏ cho bản đồ), sửa --ratio trong CSS theo tỉ lệ ảnh, rồi sửa mảng STOPS.
Zoom sâu hơn
Tăng zoom trong STOPS. Ảnh gốc phải đủ lớn: zoom 3 cần ảnh rộng khoảng 3 lần màn hình.
Thêm, bớt chi tiết
Thêm, xoá phần tử trong STOPS. Số trang cuộn tự tính lại.

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 sâu vào bức tranh · 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: #121110;
  --text: #f1ece2;
  --muted: #a39b8d;
  --accent: #d4a24c;
  --ratio: 3000 / 2012; /* tỉ lệ ảnh tranh */
}

* { 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 { max-width: 14ch; margin: 10px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }

.viewer { position: relative; height: 100vh; overflow: hidden; }
.stage { position: absolute; inset: 0; display: grid; place-items: center; overflow: hidden; }
/* Tranh vừa khít màn hình ở mức zoom 1. */
.canvas {
  width: min(92vw, calc(88vh * var(--ratio)));
  aspect-ratio: var(--ratio);
  will-change: transform;
}
.canvas img { display: block; width: 100%; height: 100%; box-shadow: 0 40px 80px -30px rgba(0, 0, 0, 0.8); }

.notes {
  position: absolute;
  left: clamp(20px, 5vw, 56px);
  bottom: clamp(20px, 5vw, 48px);
  width: min(380px, 80vw);
  min-height: 150px;
}
.note-card {
  position: absolute;
  inset: auto 0 0 0;
  padding: 20px 22px;
  border-radius: 16px;
  background: rgba(18, 17, 16, 0.78);
  backdrop-filter: blur(8px);
  opacity: 0;
}
.note-card small { color: var(--accent); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }
.note-card h2 { margin: 6px 0 0; font-size: 24px; }
.note-card p { margin: 8px 0 0; line-height: 1.6; color: var(--muted); }

.minimap {
  position: absolute;
  right: clamp(16px, 4vw, 40px);
  bottom: clamp(16px, 4vw, 40px);
  width: clamp(120px, 14vw, 180px);
  border: 1px solid rgba(241, 236, 226, 0.35);
  overflow: hidden; /* khung đỏ không tràn ra khi xem sát mép tranh */
}
.minimap img { display: block; width: 100%; height: auto; opacity: 0.8; }
.viewport { position: absolute; left: 0; top: 0; width: 100%; height: 100%; border: 2px solid 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;
  padding: 14px 26px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--bg);
  font-weight: 700;
}
.note { margin: 20px 0 0; font-size: 13px; color: var(--muted); }

@media (max-width: 760px) {
  .notes { bottom: 16px; min-height: 170px; }
  .minimap { top: 16px; bottom: auto; width: 110px; }
  .note-card h2 { font-size: 20px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Nghệ thuật · Câu chuyện tác phẩm</p>
  <h1>Nhìn gần một bức sơn mài</h1>
  <p class="lead">Cuộn xuống để đi vào từng chi tiết.</p>
</section>

<section class="viewer">
  <div class="stage">
    <!-- Ảnh càng lớn thì zoom càng nét. Demo dùng ảnh 3000px. -->
    <div class="canvas">
      <img src="https://bodev.vn/lab-assets/nghe-thuat/dam-sen-son-mai.webp" alt="Đầm sen đêm, tranh sơn mài vàng bạc trên nền đen" width="3000" height="2012" />
    </div>
  </div>

  <aside class="notes" aria-live="polite"></aside>

  <!-- Bản đồ nhỏ: khung đỏ cho biết đang xem chỗ nào trên tranh -->
  <div class="minimap" aria-hidden="true">
    <img src="https://bodev.vn/lab-assets/nghe-thuat/dam-sen-son-mai-nho.webp" alt="" width="1200" height="806" />
    <span class="viewport"></span>
  </div>
</section>

<section class="outro">
  <h2>Mỗi tác phẩm có một câu chuyện.</h2>
  <button type="button" class="btn">Xem bộ sưu tập</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>
// Đi sâu vào bức tranh — bodev.vn/lab/di-sau-vao-tranh
// Cần: gsap + ScrollTrigger.

gsap.registerPlugin(ScrollTrigger);

// Các điểm dừng. x, y: vị trí trên tranh (0 = mép trái/trên, 1 = mép phải/dưới). zoom: phóng bao nhiêu lần.
// Thay tranh khác: mở ảnh, ước lượng vị trí chi tiết theo tỉ lệ rồi điền vào đây.
const STOPS = [
  { x: 0.5, y: 0.5, zoom: 1, tag: "Toàn cảnh", title: "Đầm sen đêm", text: "Sơn mài trên vóc, 120 × 80 cm. Nền đen là sơn then, phủ và mài qua nhiều lớp." },
  { x: 0.438, y: 0.265, zoom: 3.4, tag: "Chi tiết 1", title: "Bông sen vàng", text: "Vàng lá dán từng mảnh, phủ sơn rồi mài lộ ra. Vết rạn trên nền là dấu vết của thời gian khô." },
  { x: 0.69, y: 0.5, zoom: 2.6, tag: "Chi tiết 2", title: "Đôi hạc", text: "Nét viền son đỏ vẽ sau cùng, khi bề mặt đã mài phẳng, nên sắc và gọn." },
  { x: 0.19, y: 0.84, zoom: 3.4, tag: "Chi tiết 3", title: "Đàn cá bạc", text: "Vảy cá bằng bạc lá, phủ một lớp sơn cánh gián trong nên ánh lên màu vàng ấm." },
  { x: 0.168, y: 0.4, zoom: 3, tag: "Chi tiết 4", title: "Vỏ trứng khảm", text: "Vỏ trứng vịt đập vụn, gắn từng mảnh bằng tay. Đây là những đốm trắng như sao trên mặt nước." },
  { x: 0.5, y: 0.5, zoom: 1, tag: "Toàn cảnh", title: "Nhìn lại cả bức", text: "Mỗi chi tiết là một kỹ thuật riêng. Gộp lại thành một đêm đầm sen." },
];

const canvas = document.querySelector(".canvas");
const notes = document.querySelector(".notes");
const viewport = document.querySelector(".viewport");

// Đưa điểm (x, y) về giữa màn hình ở mức zoom z. Gốc biến đổi là tâm tranh.
const cameraFor = (stop) => ({
  scale: stop.zoom,
  x: () => (0.5 - stop.x) * canvas.offsetWidth * stop.zoom,
  y: () => (0.5 - stop.y) * canvas.offsetHeight * stop.zoom,
});
// Khung trên bản đồ nhỏ: rộng 1/zoom, tâm tại (x, y). Tính theo % nên không cần đo.
const miniFor = (stop) => ({
  width: `${100 / stop.zoom}%`,
  height: `${100 / stop.zoom}%`,
  left: `${(stop.x - 0.5 / stop.zoom) * 100}%`,
  top: `${(stop.y - 0.5 / stop.zoom) * 100}%`,
});

const cards = STOPS.map((stop) => {
  const card = document.createElement("div");
  card.className = "note-card";
  const tag = document.createElement("small");
  tag.textContent = stop.tag;
  const h = document.createElement("h2");
  h.textContent = stop.title;
  const p = document.createElement("p");
  p.textContent = stop.text;
  card.append(tag, h, p);
  notes.appendChild(card);
  return card;
});

gsap.set(canvas, { ...cameraFor(STOPS[0]), transformOrigin: "50% 50%" });
gsap.set(viewport, miniFor(STOPS[0]));
gsap.set(cards[0], { opacity: 1 });

const tl = gsap.timeline({ defaults: { duration: 1, ease: "power2.inOut" } });
STOPS.slice(1).forEach((stop, k) => {
  const at = k * 1.5; // 0.5 là quãng dừng đọc chú thích
  tl.to(canvas, cameraFor(stop), at)
    .to(viewport, miniFor(stop), at)
    .to(cards[k], { opacity: 0, y: -12, duration: 0.3 }, at)
    .fromTo(cards[k + 1], { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.4 }, at + 0.7);
});
tl.to({}, { duration: 0.5 }); // giữ cảnh cuối một nhịp

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Không zoom: hiện toàn cảnh và chú thích đầu.
  tl.progress(0).kill();
} else {
  ScrollTrigger.create({
    trigger: ".viewer",
    start: "top top",
    end: () => "+=" + window.innerHeight * STOPS.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 Nghệ thuậtBức tường vô tận