Phối màu bằng vòng xoay
Cuộn làm xoay vòng màu, cả căn phòng đổi phong cách: Scandinavian, Indochine, Japandi…
Cách hoạt động
- 1.Màu của phòng lấy từ 5 biến CSS: --wall, --floor, --sofa, --accent, --wood. GSAP tween thẳng các biến này.
- 2.Vòng màu vẽ bằng SVG, mỗi phong cách một múi. Cuộn xoay vòng để múi đang chọn nằm dưới mũi tên.
- 3.snap trong ScrollTrigger: thả tay là vòng tự dừng đúng một phong cách, không đứng lưng chừng giữa hai bộ màu.
Thử chỉnh
- Thêm phong cách
- Thêm một mục vào mảng STYLES. Vòng tự chia lại số múi, số trang cuộn.
- Dùng ảnh phòng của bạn
- Xem bản "Ảnh thật": mỗi phong cách một ảnh .photo, đúng thứ tự STYLES. Ảnh phải cùng góc máy, cùng khung hình.
- Có một ảnh, muốn ra 5 phong cách
- Dùng AI chỉnh ảnh (ví dụ Nano Banana): giữ nguyên góc máy và vị trí đồ, chỉ đổi vật liệu, màu. Bản demo làm đúng cách này.
- Bỏ tự dừng
- Xoá dòng snap trong ScrollTrigger.
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>Phối màu bằng vòng xoay · 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 {
/* Giá trị ban đầu = phong cách đầu tiên trong JS. */
--wall: #eeeae3;
--floor: #d9c3a0;
--sofa: #b8c4c2;
--accent: #2f3e46;
--wood: #c49a6c;
--ink: #1f1d1a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Be Vietnam Pro", system-ui, sans-serif;
background: #f4f1ec;
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: #9a6b45; 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: #8a8177; }
.studio {
height: 100vh;
display: grid;
grid-template-columns: 1.5fr 1fr;
align-items: center;
gap: clamp(16px, 4vw, 48px);
padding: 24px clamp(20px, 5vw, 64px);
background: var(--wall);
overflow: hidden;
}
.room { width: 100%; max-height: 80vh; border-radius: 20px; }
.room .wall { fill: var(--wall); }
.room .floor { fill: var(--floor); }
.room .sofa { fill: var(--sofa); }
.room .shade { filter: brightness(0.9); }
.room .accent { fill: var(--accent); }
.room .wood { fill: var(--wood); }
.room .plant { fill: #5d7f55; }
.panel { display: flex; flex-direction: column; align-items: center; gap: 24px; }
.names { position: relative; width: 100%; height: 150px; }
.name {
position: absolute;
inset: 0;
text-align: center;
opacity: 0;
}
.name h2 { margin: 0; font-size: clamp(30px, 4vw, 52px); font-weight: 800; line-height: 1.05; }
.name p { max-width: 30ch; margin: 10px auto 0; font-size: 15px; line-height: 1.5; opacity: 0.75; }
.name.dark { color: #f4f1ec; }
.dial-wrap { position: relative; width: min(300px, 70vw); aspect-ratio: 1; }
.dial { width: 100%; height: 100%; overflow: visible; }
.pointer {
position: absolute;
top: -14px;
left: 50%;
translate: -50% 0;
border: 12px solid transparent;
border-top: 18px solid var(--accent);
z-index: 1;
}
.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;
display: inline-block;
padding: 14px 26px;
border-radius: 999px;
background: var(--ink);
color: #fff;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 760px) {
.studio { grid-template-columns: 1fr; grid-template-rows: auto 1fr; align-content: center; }
.names { height: 120px; }
.dial-wrap { width: 200px; }
}
/* Bản anh-that */
/* Ảnh xếp chồng. Ảnh đầu hiện sẵn, các ảnh sau ẩn, JS hiện dần khi xoay. */
.photos {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 20px;
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.45);
}
.photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
}
.photo:first-child { opacity: 1; }
</style>
</head>
<body>
<section class="intro">
<p class="kicker">Nội thất · Chọn phong cách</p>
<h1>Một căn phòng, năm phong cách</h1>
<p class="lead">Cuộn để xoay vòng màu.</p>
</section>
<section class="studio">
<!--
Mỗi phong cách một ảnh .photo, đúng thứ tự mảng STYLES trong JS.
Các ảnh phải cùng góc chụp, cùng khung hình, thì chuyển cảnh mới mượt.
-->
<div class="room photos">
<img class="photo" src="https://bodev.vn/lab-assets/phoi-mau-vong-xoay/scandinavian.webp" alt="Phòng khách phong cách Scandinavian" width="1600" height="893" />
<img class="photo" src="https://bodev.vn/lab-assets/phoi-mau-vong-xoay/indochine.webp" alt="Phòng khách phong cách Indochine" width="1600" height="893" />
<img class="photo" src="https://bodev.vn/lab-assets/phoi-mau-vong-xoay/japandi.webp" alt="Phòng khách phong cách Japandi" width="1600" height="893" />
<img class="photo" src="https://bodev.vn/lab-assets/phoi-mau-vong-xoay/wabi-sabi.webp" alt="Phòng khách phong cách Wabi-sabi" width="1600" height="893" />
<img class="photo" src="https://bodev.vn/lab-assets/phoi-mau-vong-xoay/hien-dai-toi.webp" alt="Phòng khách phong cách hiện đại tối" width="1600" height="893" />
</div>
<div class="panel">
<div class="names" aria-live="polite"></div>
<div class="dial-wrap">
<span class="pointer" aria-hidden="true"></span>
<svg class="dial" viewBox="-110 -110 220 220" aria-hidden="true"></svg>
</div>
</div>
</section>
<section class="outro">
<h2>Chưa chọn được phong cách?</h2>
<button type="button" class="btn">Nhận 3 phương án phối màu</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>
// Phối màu bằng vòng xoay — bodev.vn/lab/phoi-mau-vong-xoay
// Cần: gsap + ScrollTrigger.
gsap.registerPlugin(ScrollTrigger);
// Mỗi phong cách là một bộ màu. Thêm, bớt, sửa tuỳ ý: vòng xoay tự chia lại.
const STYLES = [
{ name: "Scandinavian", note: "Gỗ sáng, tường trắng ngà, điểm xanh than.", dark: false,
colors: { wall: "#eeeae3", floor: "#d9c3a0", sofa: "#b8c4c2", accent: "#2f3e46", wood: "#c49a6c" } },
{ name: "Indochine", note: "Gỗ nâu đậm, xanh cổ vịt, vàng đồng.", dark: false,
colors: { wall: "#f1e3c8", floor: "#7a4b2a", sofa: "#1f5c4f", accent: "#b8862b", wood: "#5a3620" } },
{ name: "Japandi", note: "Tối giản, màu đất, đường nét thấp.", dark: false,
colors: { wall: "#e8e1d6", floor: "#b89f7e", sofa: "#6f6a5f", accent: "#2b2b28", wood: "#8a6d4b" } },
{ name: "Wabi-sabi", note: "Tường vữa thô, vải lanh, gỗ mộc.", dark: false,
colors: { wall: "#d8cbb8", floor: "#a58f76", sofa: "#efe7da", accent: "#7b5e46", wood: "#6e5a47" } },
{ name: "Hiện đại tối", note: "Tường xám than, sofa cam gạch, đèn vàng.", dark: true,
colors: { wall: "#2b2d31", floor: "#3d3a36", sofa: "#c46a3c", accent: "#e7c27d", wood: "#1c1c1c" } },
];
const NS = "http://www.w3.org/2000/svg";
const dialSvg = document.querySelector(".dial");
// Xoay một nhóm <g> bên trong, không xoay thẻ <svg> gốc (xoay svg gốc sẽ lệch tâm).
const dial = document.createElementNS("http://www.w3.org/2000/svg", "g");
dialSvg.appendChild(dial);
const names = document.querySelector(".names");
const root = document.documentElement;
const SLICE = 360 / STYLES.length;
// ---------- 1. Vẽ vòng màu: mỗi phong cách một múi, chia 3 màu ----------
function arc(r, a0, a1) {
const p = (a) => [Math.sin((a * Math.PI) / 180) * r, -Math.cos((a * Math.PI) / 180) * r];
const [x0, y0] = p(a0);
const [x1, y1] = p(a1);
return { x0, y0, x1, y1 };
}
function wedge(rIn, rOut, a0, a1, fill) {
const o = arc(rOut, a0, a1);
const i = arc(rIn, a0, a1);
const path = document.createElementNS(NS, "path");
path.setAttribute("d", `M${o.x0} ${o.y0} A${rOut} ${rOut} 0 0 1 ${o.x1} ${o.y1} L${i.x1} ${i.y1} A${rIn} ${rIn} 0 0 0 ${i.x0} ${i.y0}Z`);
path.setAttribute("fill", fill);
path.setAttribute("stroke", "#fff");
path.setAttribute("stroke-width", "1.5");
dial.appendChild(path);
}
STYLES.forEach((style, i) => {
// Múi của phong cách i nằm giữa đỉnh vòng khi vòng xoay -i * SLICE.
const a0 = i * SLICE - SLICE / 2;
const a1 = a0 + SLICE;
wedge(70, 104, a0, a1, style.colors.sofa);
wedge(44, 70, a0, a1, style.colors.accent);
wedge(22, 44, a0, a1, style.colors.wood);
const card = document.createElement("div");
card.className = style.dark ? "name dark" : "name";
const h = document.createElement("h2");
h.textContent = style.name;
const p = document.createElement("p");
p.textContent = style.note;
card.append(h, p);
names.appendChild(card);
});
const hub = document.createElementNS(NS, "circle");
hub.setAttribute("r", "18");
hub.setAttribute("fill", "#fff");
dialSvg.appendChild(hub);
// ---------- 2. Timeline: xoay vòng + đổi biến màu ----------
const cards = names.querySelectorAll(".name");
// Bản ảnh thật: mỗi phong cách một ảnh .photo, xếp chồng theo đúng thứ tự STYLES.
const photos = document.querySelectorAll(".photo");
const vars = (c) => ({ "--wall": c.wall, "--floor": c.floor, "--sofa": c.sofa, "--accent": c.accent, "--wood": c.wood });
gsap.set(cards[0], { opacity: 1 });
const tl = gsap.timeline({ defaults: { duration: 1, ease: "power2.inOut" } });
STYLES.slice(1).forEach((style, k) => {
const i = k + 1;
tl.to(dial, { rotation: -i * SLICE, svgOrigin: "0 0" }, k)
.to(root, vars(style.colors), k)
.to(cards[i - 1], { opacity: 0, y: -16, duration: 0.4 }, k)
.fromTo(cards[i], { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.4 }, k + 0.5);
// Ảnh sau nằm trên ảnh trước: chỉ cần hiện dần ảnh sau là thành crossfade.
if (photos[i]) tl.to(photos[i], { opacity: 1 }, k);
});
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
// Không cuộn-xoay: bấm vào vòng để đổi phong cách.
let current = 0;
document.querySelector(".dial-wrap").addEventListener("click", () => {
current = (current + 1) % STYLES.length;
tl.progress(current / (STYLES.length - 1));
});
} else {
ScrollTrigger.create({
trigger: ".studio",
start: "top top",
end: () => "+=" + window.innerHeight * (STYLES.length - 1),
pin: true,
scrub: 0.6,
animation: tl,
// Thả tay thì vòng tự dừng ở phong cách gần nhất. inertia: false để cuộn mạnh không văng qua mấy phong cách.
snap: { snapTo: 1 / (STYLES.length - 1), inertia: false, duration: { min: 0.2, max: 0.6 }, ease: "power1.inOut" },
});
}
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.