本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

セクション

カーソルを合わせた部屋が持ち上がり、名前と広さが表示される立体の間取り図

デモ

操作:カーソルを重ねる・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM+WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

間取り図の部屋にカーソルを合わせると、その部屋の床だけが少し持ち上がって明るいベージュに変わり、上に「LDK 18.5畳」のような名前と広さのラベルが中央から左右へ開きます。左の一覧でも、同じ部屋の行の背景色が変わります。その間も、図全体はゆっくり左右へ向きを変え続けています。

どの部屋に重なっているかは、Raycaster でポインタから線を飛ばして決めています。線を当てるのは床のメッシュではなく、床の上面の高さに置いた見えない平面(THREE.Plane)で、当たった点の座標を部屋ごとの四角と比べます。持ち上がった床そのものに当てると、床が上がるたびに当たる点が手前へずれます。部屋の境目でカーソルを止めていても、隣り合う部屋が交互に上がったり下がったりしてしまうのです。

部屋ごとに持つ値は、持ち上がり(0〜1)の1つだけ。床の高さと色、ラベルの開き具合、一覧の行の背景色は、すべてこの値から描いています。上がるときは0.45秒の power2.out、下がるときは0.4秒の power2.inOut です。持ち上がる高さは壁より低い0.5mに抑えて、部屋が壁の内側からせり上がるように見せました。壁より高く上げれば部屋は目立ちますが、壁とのつながりが切れて、間取り図らしさが薄れます。

部屋の位置と名前・広さは HTML の一覧に書いてあり、スクリプトはそこから床とラベルを組み立てます。一覧の行はボタンなので、カーソルを重ねてもフォーカスしても同じ部屋が持ち上がり、押せばその部屋を選んだままにして aria-pressed で伝えます。ホバーのないタッチ端末では、図の部屋をタップしても選べます。立体のほうは一覧と同じ内容を描いているだけなので、スクリーンリーダーからは外しました。

コード

HTML
<section class="floorplan" aria-labelledby="floorplan-title">
  <div class="floorplan__text">
    <p class="floorplan__eyebrow">FLOOR PLAN</p>
    <h2 class="floorplan__title" id="floorplan-title">間取り</h2>
    <p class="floorplan__meta">2LDK・専有面積 68.4㎡</p>
    <p class="floorplan__lead">ここに説明文が入ります。</p>
    <!-- 部屋の位置は data-rect(左・奥・右・手前の端。メートル)。script.js がこの一覧から、持ち上がる床とラベルを組む -->
    <ul class="floorplan__rooms">
      <li><button type="button" aria-pressed="false" data-rect="0 2.6 6 7.6"><span>LDK</span><span class="floorplan__area">18.5畳</span></button></li>
      <li><button type="button" aria-pressed="false" data-rect="6 4.36 9 7.6"><span>洋室</span><span class="floorplan__area">6.0畳</span></button></li>
      <li><button type="button" aria-pressed="false" data-rect="0 0 3.1 2.6"><span>洋室</span><span class="floorplan__area">5.0畳</span></button></li>
      <li><button type="button" aria-pressed="false" data-rect="7.4 2.34 9 4.36"><span>WIC</span><span class="floorplan__area">2.0畳</span></button></li>
    </ul>
  </div>
  <!-- 立体は一覧と同じ内容を描くだけなので、スクリーンリーダーからは外す -->
  <div class="floorplan__stage" aria-hidden="true"><canvas></canvas></div>
</section>

CSS
.floorplan {
  /* 色はここで決める。script.js が --room から --ground までを読んで、立体の床・壁・光の色にする */
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgba(38, 38, 38, 0.12);
  --room: #d9c2a0;   /* 持ち上がる部屋の床(淡いベージュ) */
  --lifted: #f3e4ca; /* 持ち上がった床 */
  --hall: #afa391;   /* 廊下・玄関 */
  --wet: #a7aeb2;    /* 浴室・洗面室・トイレ */
  --wall: #fbfaf7;   /* 壁(白に近いオフホワイト) */
  --base: #dad7d0;   /* 図の下の台(背景色より少し暗いグレー) */
  --ground: #c9c3b8; /* 下から回り込む光 */
  --label-area: #6b6b6b; /* ラベルの中の広さの文字 */
  display: grid;
  grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
  align-items: center;
  gap: 32px;
  padding: 64px clamp(16px, 7vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.floorplan__eyebrow { margin: 0; color: var(--mute-small); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.floorplan__title { margin: 20px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.floorplan__meta { margin: 16px 0 0; color: var(--mute); font-size: 16px; font-weight: 600; line-height: 1.5; letter-spacing: 0.04em; }
.floorplan__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

.floorplan__rooms { margin: 32px 0 0; padding: 0; border-top: 1px solid var(--line); list-style: none; }
/* --lift(0〜1)は script.js が入れる。部屋の床が持ち上がるのに合わせて、行の背景色と印が変わる */
.floorplan__rooms button {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  height: 48px;
  padding: 0 12px;
  border: 0;
  box-shadow: inset 0 -1px 0 var(--line);
  background: color-mix(in srgb, var(--surface) calc(var(--lift, 0) * 100%), transparent);
  color: inherit;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  text-align: left;
  cursor: pointer;
}
.floorplan__rooms button::before {
  content: "";
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 2px;
  background: var(--room);
  opacity: calc(0.35 + var(--lift, 0) * 0.65);
  scale: calc(0.5 + var(--lift, 0) * 0.5);
}
.floorplan__rooms button > span:first-child { flex: 1; }
.floorplan__area { color: var(--mute-small); font-weight: 400; }
.floorplan__rooms button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }

/* 立体の表示領域。ラベルもこの中に置く */
.floorplan__stage { position: relative; aspect-ratio: 832 / 720; overflow: clip; }
.floorplan__stage canvas { display: block; width: 100%; height: 100%; }

/* 部屋のラベル。位置と開き具合(--open)は script.js が入れ、中央から左右へ開く */
.floorplan__label {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  padding: 0 16px;
  border-radius: 999px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-size: 15px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
  clip-path: inset(0 calc((1 - var(--open, 0)) * 50%) round 999px);
}
.floorplan__label .floorplan__area { color: var(--label-area); font-size: 14px; font-weight: 600; }

@media (max-width: 860px) {
  .floorplan { grid-template-columns: minmax(0, 1fr); padding: 48px 16px; }
  .floorplan__stage { aspect-ratio: 6 / 5; margin: 0 -16px; }
  .floorplan__label { height: 30px; padding: 0 12px; font-size: 13px; }
  .floorplan__label .floorplan__area { font-size: 12px; }
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";

const W = 9, D = 7.6; // 間取り全体の幅と奥行き(メートル。x は左 → 右、z は奥 → 手前)
const SLAB = 0.12, LIFT = 0.5, WALL = 0.7, SILL = 0.22, OUTER = 0.16, INNER = 0.1; // 床の厚み・持ち上がる高さ・壁と窓の下の壁の高さ・外壁と内壁の厚み
const RISE = 0.45, FALL = 0.4; // 床が持ち上がる秒数(power2.out)と下がる秒数(power2.inOut)
const AZIMUTH = -0.22, SWAY = 0.09, PERIOD = 9.6, ELEVATION = 0.92, DISTANCE = 24; // カメラの向き・左右に振れる量・1 往復の秒数・見下ろす角度・距離
// 持ち上がらない床 [左, 奥, 右, 手前, 色の CSS 変数](浴室・洗面室・トイレ・廊下・玄関)
const FLOORS = [[3.1, 0, 4.9, 1.6, "--wet"], [3.1, 1.6, 4.9, 2.6, "--wet"], [4.9, 0, 6, 1.6, "--wet"], [6, 0, 7.4, 4.36, "--hall"], [4.9, 1.6, 6, 2.6, "--hall"], [7.4, 0, 9, 2.34, "--hall"]];
// 外壁 [x0, z0, x1, z1, 高さ] と内壁 [x0, z0, x1, z1]。すき間はドア、低い壁は窓の下
const OUTER_WALLS = [[0, 0, 7.75, 0, WALL], [8.65, 0, W, 0, WALL], [0, 0, 0, D, WALL], [W, 0, W, D, WALL], [0, D, 0.6, D, WALL], [0.6, D, 4.6, D, SILL], [4.6, D, 6.4, D, WALL], [6.4, D, 8.6, D, SILL], [8.6, D, W, D, WALL]];
const INNER_WALLS = [[0, 2.6, 2, 2.6], [2.8, 2.6, 5.05, 2.6], [5.85, 2.6, 6, 2.6], [6, 2.6, 6, D], [6, 4.36, 6.3, 4.36], [7.1, 4.36, W, 4.36], [7.4, 2.34, 7.4, 2.9], [7.4, 3.7, 7.4, 4.36],
  [7.4, 2.34, W, 2.34], [3.1, 0, 3.1, 2.6], [3.1, 1.6, 3.6, 1.6], [4.4, 1.6, 4.9, 1.6], [4.9, 0, 4.9, 1.75], [4.9, 2.45, 4.9, 2.6], [4.9, 1.6, 5.15, 1.6], [5.75, 1.6, 6, 1.6], [6, 0, 6, 1.6]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".floorplan").forEach((plan) => {
  const css = (name) => getComputedStyle(plan).getPropertyValue(name).trim();
  const stage = plan.querySelector(".floorplan__stage"), buttons = [...plan.querySelectorAll(".floorplan__rooms button")];
  const renderer = new THREE.WebGLRenderer({ canvas: stage.querySelector("canvas"), antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.NeutralToneMapping; Object.assign(renderer.shadowMap, { enabled: true, type: THREE.PCFShadowMap });
  const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); scene.background = new THREE.Color(css("--bg"));
  const sun = new THREE.DirectionalLight("#ffffff", 2.2); sun.position.set(-5, 12, 6); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);
  Object.assign(sun.shadow.camera, { left: -8, right: 8, top: 8, bottom: -8, near: 1, far: 40 }); Object.assign(sun.shadow, { bias: -0.0004, normalBias: 0.02 });
  scene.add(sun, new THREE.HemisphereLight("#ffffff", css("--ground"), 1));
  const box = new THREE.BoxGeometry(1, 1, 1), wallBox = new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), materials = [];
  const add = (geometry, color, roughness, [x, y, z], [sx, sy, sz]) => {
    const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color, roughness })); materials.push(mesh.material);
    Object.assign(mesh, { castShadow: true, receiveShadow: true }).position.set(x, y, z); mesh.scale.set(sx, sy, sz); scene.add(mesh); return mesh;
  };
  // 床は壁の厚みの半分だけ内側に収め、持ち上がっても壁に食い込まない
  const slab = ([x0, z0, x1, z1], color) => add(box, color, 0.8, [(x0 + x1) / 2 - W / 2, SLAB / 2, (z0 + z1) / 2 - D / 2], [x1 - x0 - INNER, SLAB, z1 - z0 - INNER]);
  const wall = ([x0, z0, x1, z1, h = WALL], thick) => { // 外壁は角で重なるよう、厚みの分だけ延ばす
    const upright = x0 === x1, length = (upright ? z1 - z0 : x1 - x0) + (thick === OUTER ? thick : 0);
    add(wallBox, css("--wall"), 0.75, [(x0 + x1) / 2 - W / 2, 0, (z0 + z1) / 2 - D / 2], [length, h, thick]).rotation.y = upright ? Math.PI / 2 : 0;
  };
  add(box, css("--base"), 0.9, [0, -0.15, 0], [W + 0.9, 0.3, D + 0.9]); // 図の下の台
  FLOORS.forEach(([x0, z0, x1, z1, color]) => slab([x0, z0, x1, z1], css(color)));
  OUTER_WALLS.forEach((w) => wall(w, OUTER)); INNER_WALLS.forEach((w) => wall(w, INNER));
  // 持ち上がる部屋は一覧のボタンから作る。ラベルの文字も、ボタンの名前と広さをそのまま使う。v は持ち上がり(0〜1)
  const [roomColor, liftedColor] = [new THREE.Color(css("--room")), new THREE.Color(css("--lifted"))];
  const rooms = buttons.map((button) => {
    const label = stage.appendChild(Object.assign(document.createElement("span"), { className: "floorplan__label" })), rect = button.dataset.rect.split(" ").map(Number);
    label.append(...[...button.children].map((child) => child.cloneNode(true)));
    return { button, rect, label, floor: slab(rect, roomColor.clone()), v: 0, tween: null };
  });
  // どの部屋に重なっているかは、ポインタから床の上面の高さの平面へ線を飛ばし、当たった点を部屋の四角と比べて決める。
  // 持ち上がった床そのものに当てると、当たる高さが変わるたびに点が手前へずれて、部屋の境目で行き来する
  const ray = new THREE.Raycaster(), plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -SLAB), hit = new THREE.Vector3(), ndc = new THREE.Vector2();
  const roomAt = ({ clientX, clientY }) => {
    const r = stage.getBoundingClientRect();
    ray.setFromCamera(ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1), camera);
    if (!ray.ray.intersectPlane(plane, hit)) return -1;
    return rooms.findIndex(({ rect: [x0, z0, x1, z1] }) => hit.x + W / 2 >= x0 && hit.x + W / 2 <= x1 && hit.z + D / 2 >= z0 && hit.z + D / 2 <= z1);
  };
  // 持ち上げる部屋は、立体に重ねた部屋 → 一覧で重ねたかフォーカスした行 → 押して選んだ部屋 の順に決める
  let pointer = null, hovered = -1, row = -1, pinned = -1, active = -1;
  const update = () => {
    const next = hovered >= 0 ? hovered : row >= 0 ? row : pinned;
    if (next !== active) rooms.forEach((room, i) => { // 前のトゥイーンを止めて、いまの値から向きを変える
      const up = i === next; room.tween?.kill(); active = next;
      room.tween = gsap.to(room, { v: up ? 1 : 0, duration: reduceMotion ? 0 : up ? RISE : FALL, ease: up ? "power2.out" : "power2.inOut" });
    });
  };
  const pin = (i) => { pinned = i === pinned ? -1 : i; buttons.forEach((b, k) => b.setAttribute("aria-pressed", String(k === pinned))); update(); };
  stage.addEventListener("pointermove", (e) => { if (e.pointerType !== "touch") pointer = e; }); stage.addEventListener("pointerleave", () => { pointer = null; hovered = -1; update(); });
  stage.addEventListener("click", (e) => pin(roomAt(e))); // タッチ端末では、タップで部屋を選ぶ
  buttons.forEach((button, i) => {
    const set = (value) => () => { row = value; update(); };
    for (const [type, value] of [["pointerenter", i], ["pointerleave", -1], ["focus", i], ["blur", -1]]) button.addEventListener(type, set(value));
    button.addEventListener("click", () => pin(i));
  });
  const anchor = new THREE.Vector3(); let now = 0, start = null;
  const draw = (time) => {
    now = time;
    const azimuth = AZIMUTH + (reduceMotion ? 0 : SWAY * Math.sin((2 * Math.PI * time) / PERIOD)); // 図はゆっくり左右に向きを変える
    camera.position.set(Math.sin(azimuth), 0, Math.cos(azimuth)).multiplyScalar(DISTANCE * Math.cos(ELEVATION)).y = DISTANCE * Math.sin(ELEVATION);
    camera.lookAt(0, 0, 0.35); camera.updateMatrixWorld();
    if (pointer) { hovered = roomAt(pointer); update(); } // 向きが変わると、止まったポインタの下の部屋も変わる
    const sizes = rooms.map(({ label }) => [label.offsetWidth, label.offsetHeight]);
    rooms.forEach(({ rect: [x0, z0, x1, z1], floor, label, button, v }, i) => {
      floor.position.y = SLAB / 2 + v * LIFT; floor.material.color.lerpColors(roomColor, liftedColor, v); floor.material.emissive.setScalar(v * 0.06);
      anchor.set((x0 + x1) / 2 - W / 2, SLAB + v * LIFT, (z0 + z1) / 2 - D / 2).project(camera);
      const open = Math.min(1, v * 1.6), [w, h] = sizes[i]; // ラベルは部屋の中心の上。開くあいだ 8px 下から上がる
      const x = Math.round(((anchor.x + 1) / 2) * stage.clientWidth - w / 2), y = Math.round(((1 - anchor.y) / 2) * stage.clientHeight - h - 14 + (1 - open) * 8);
      label.style.transform = `translate(${x}px, ${y}px)`; label.style.setProperty("--open", open); button.style.setProperty("--lift", v);
    });
    renderer.render(scene, camera);
  };
  const resize = new ResizeObserver(() => { // 832:720 より縦長の表示領域では引いて、横に収める
    const aspect = stage.clientWidth / stage.clientHeight; Object.assign(camera, { aspect, zoom: Math.min(1, aspect / (832 / 720)) }).updateProjectionMatrix();
    renderer.setSize(stage.clientWidth, stage.clientHeight, false); draw(now); // 大きさは CSS が決めるので、style は書き換えない
  });
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting ? (ms) => draw((ms - (start ??= ms)) / 1000) : null));
  resize.observe(stage); view.observe(plan);
  plan.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    renderer.setAnimationLoop(null); resize.disconnect(); view.disconnect(); rooms.forEach((room) => room.tween?.kill());
    [box, wallBox, ...materials].forEach((item) => item.dispose()); renderer.dispose(); renderer.forceContextLoss();
  };
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。