bodev.vn

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

Tiếp trong Nội thấtLắp ráp căn phòng