bodev.vn

Hồ sơ dự án xếp chồng

Thẻ dự án trượt lên chồng lên nhau như xấp hồ sơ. Thẻ cũ lùi ra sau, bộ đếm lật số.

Cách hoạt động

  1. 1.Các thẻ nằm chồng tại cùng một chỗ. Thẻ mới trượt từ dưới lên, thẻ cũ nhỏ lại, dịch lên và tối đi theo độ sâu.
  2. 2.Mỗi thẻ nằm lệch một góc nhỏ (mảng TILT), nhìn như xấp giấy thật chứ không phải slider.
  3. 3.Bộ đếm là một cột số trong cửa sổ chỉ hở một dòng. Dịch cột lên là số lật.
  4. 4.snap: thả tay là dừng đúng một thẻ.

Thử chỉnh

Thêm, bớt dự án
Copy hoặc xoá một thẻ .card. Sửa luôn danh sách số trong .counter-nums và số tổng.
Xấp dày hơn, lệch nhiều hơn
Tăng DEPTH.y, DEPTH.scale hoặc các góc trong TILT.
Dùng ảnh công trình của bạn
Đổi src ảnh. Ảnh ngang 16:9 hoặc 3:2 là đẹp nhất.

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>Hồ sơ dự án xếp chồng · 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: #efebe4;
  --ink: #1e1d1b;
  --muted: #74706a;
  --accent: #c0582f;
  --card: #fbf9f5;
}

* { 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: 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); }

.stack {
  height: 100vh;
  display: flex;
  flex-direction: column;
  padding: 28px clamp(20px, 5vw, 64px) 36px;
  overflow: hidden;
}
.stack-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
.stack-head h2 { margin: 0; font-size: clamp(22px, 3vw, 36px); }

/* Bộ đếm kiểu đồng hồ lật: một cột số, cửa sổ chỉ hở một dòng. */
.counter { display: flex; align-items: baseline; gap: 6px; margin: 0; font-weight: 800; font-variant-numeric: tabular-nums; }
.counter-window { display: inline-block; height: 1.2em; overflow: hidden; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.2; color: var(--accent); }
.counter-nums { display: flex; flex-direction: column; }
.counter > span:last-child { color: var(--muted); }

.deck { position: relative; flex: 1; margin-top: 20px; }
.card {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: 1.6fr 1fr;
  overflow: hidden;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.35);
  will-change: transform;
}
.card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.meta { display: flex; flex-direction: column; justify-content: flex-end; padding: clamp(18px, 3vw, 36px); }
.meta-top { margin: 0; font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.meta h3 { margin: 10px 0 0; font-size: clamp(26px, 3vw, 40px); line-height: 1.05; }
.meta p { margin: 12px 0 0; line-height: 1.6; color: var(--muted); }
.meta dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 22px 0 0; padding-top: 18px; border-top: 1px solid rgba(0, 0, 0, 0.1); }
.meta dt { font-size: 12px; color: var(--muted); }
.meta dd { margin: 4px 0 0; font-weight: 700; }

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

@media (max-width: 760px) {
  .card { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; }
  .meta { justify-content: flex-start; padding: 16px; }
  .meta p { font-size: 14px; }
  .meta dl { margin-top: 12px; padding-top: 12px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Kiến trúc · Hồ sơ năng lực</p>
  <h1>Dự án tiêu biểu</h1>
  <p class="lead">Cuộn xuống để lật từng hồ sơ.</p>
</section>

<section class="stack">
  <header class="stack-head">
    <h2>12 năm, 140 công trình</h2>
    <p class="counter" aria-hidden="true">
      <span class="counter-window"><span class="counter-nums"><span>01</span><span>02</span><span>03</span><span>04</span></span></span>
      <span>/ 04</span>
    </p>
  </header>

  <!-- Mỗi .card là một dự án. Thêm, bớt thẻ tuỳ ý: JS tự tính lại. Nhớ sửa số trong .counter-nums. -->
  <div class="deck">
    <article class="card">
      <img src="https://bodev.vn/lab-assets/ho-so-du-an/biet-thu-da-nang.webp" alt="Biệt thự nhiệt đới với hồ bơi dài lúc chạng vạng" width="1400" height="782" />
      <div class="meta">
        <p class="meta-top">Nhà ở · Đà Nẵng · 2024</p>
        <h3>Biệt thự Sơn Trà</h3>
        <p>Bê tông vân gỗ, lam gỗ và hồ bơi dài 24m nối nhà với vườn nhiệt đới.</p>
        <dl><div><dt>Diện tích sàn</dt><dd>620 m²</dd></div><div><dt>Phạm vi</dt><dd>Kiến trúc, nội thất</dd></div></dl>
      </div>
    </article>
    <article class="card">
      <img src="https://bodev.vn/lab-assets/ho-so-du-an/van-phong-ha-noi.webp" alt="Tòa văn phòng mặt tiền phủ cây xanh" width="1400" height="782" />
      <div class="meta">
        <p class="meta-top">Văn phòng · Hà Nội · 2023</p>
        <h3>Văn phòng Xanh Cầu Giấy</h3>
        <p>Mặt tiền 7 tầng trồng cây, giảm 3°C nhiệt độ trong nhà so với khu xung quanh.</p>
        <dl><div><dt>Diện tích sàn</dt><dd>4.200 m²</dd></div><div><dt>Phạm vi</dt><dd>Kiến trúc, cảnh quan</dd></div></dl>
      </div>
    </article>
    <article class="card">
      <img src="https://bodev.vn/lab-assets/ho-so-du-an/cafe-sai-gon.webp" alt="Quán café mặt tiền gạch bông gió đất nung" width="1400" height="782" />
      <div class="meta">
        <p class="meta-top">Thương mại · TP.HCM · 2022</p>
        <h3>Café Gạch Gió</h3>
        <p>Mặt tiền gạch bông gió đất nung, thông gió tự nhiên, không cần máy lạnh tầng trệt.</p>
        <dl><div><dt>Diện tích sàn</dt><dd>180 m²</dd></div><div><dt>Phạm vi</dt><dd>Kiến trúc</dd></div></dl>
      </div>
    </article>
    <article class="card">
      <img src="https://bodev.vn/lab-assets/ho-so-du-an/truong-ha-giang.webp" alt="Trường mầm non tường đất nện mái tre giữa núi" width="1400" height="782" />
      <div class="meta">
        <p class="meta-top">Giáo dục · Hà Giang · 2021</p>
        <h3>Mầm non Lũng Cú</h3>
        <p>Tường đất nện, khung tre, sân tròn ở giữa. Người dân địa phương cùng làm.</p>
        <dl><div><dt>Diện tích sàn</dt><dd>350 m²</dd></div><div><dt>Phạm vi</dt><dd>Thiện nguyện</dd></div></dl>
      </div>
    </article>
  </div>
</section>

<section class="outro">
  <h2>Xem đủ 140 công trình?</h2>
  <button type="button" class="btn">Tải hồ sơ năng lực (PDF)</button>
</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>
// Hồ sơ dự án xếp chồng — bodev.vn/lab/ho-so-du-an
// Cần: gsap + ScrollTrigger.

gsap.registerPlugin(ScrollTrigger);

const cards = gsap.utils.toArray(".card");
const nums = document.querySelector(".counter-nums");
// Mỗi thẻ nằm lệch một góc nhỏ, như xấp hồ sơ giấy. Sửa tuỳ ý.
const TILT = [-1.5, 1.2, -0.8, 1.6, -1.2, 0.9];
// Thẻ cũ lùi ra sau: nhỏ lại, dịch lên, tối đi theo độ sâu.
const DEPTH = { scale: 0.05, y: -18, dim: 0.12 };

// Thẻ đầu nằm sẵn. Các thẻ sau chờ bên dưới khung.
// filter phải có giá trị đầu là brightness(1): tween từ "none" thì GSAP không nội suy được.
gsap.set(cards, { filter: "brightness(1)" });
gsap.set(cards[0], { rotation: TILT[0] });
// 140%: đủ sâu để góc thẻ đang xoay không ló lên mép khung.
gsap.set(cards.slice(1), { yPercent: 140, rotation: 6 });

const tl = gsap.timeline({ defaults: { duration: 1, ease: "power3.out" } });
cards.slice(1).forEach((card, k) => {
  const i = k + 1; // thẻ đang trượt lên
  tl.to(card, { yPercent: 0, rotation: TILT[i % TILT.length] }, k);
  // Mọi thẻ đã có trên xấp lùi thêm một bậc.
  cards.slice(0, i).forEach((under, j) => {
    const depth = i - j;
    tl.to(
      under,
      { scale: 1 - DEPTH.scale * depth, y: DEPTH.y * depth, filter: `brightness(${1 - DEPTH.dim * depth})` },
      k,
    );
  });
  tl.to(nums, { yPercent: (-100 / cards.length) * i, ease: "power2.inOut" }, k);
});

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Không xếp chồng: trải các thẻ ra, cuộn bình thường.
  tl.progress(0).kill();
  gsap.set(cards, { clearProps: "all" });
  document.querySelector(".deck").style.cssText = "display:grid;gap:24px";
  cards.forEach((c) => {
    c.style.position = "relative";
    c.style.minHeight = "70vh";
  });
  document.querySelector(".stack").style.height = "auto";
} else {
  ScrollTrigger.create({
    trigger: ".stack",
    start: "top top",
    end: () => "+=" + window.innerHeight * (cards.length - 1),
    pin: true,
    scrub: 1,
    animation: tl,
    // Thả tay là dừng ở một thẻ, không lưng chừng.
    snap: { snapTo: 1 / (cards.length - 1), inertia: false, duration: { min: 0.2, max: 0.5 } },
    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 Kiến trúc & kỹ sưMặt cắt thông gió nhà ống