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