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

カード

棚の立体の本にカーソルを合わせると、引き出されて書名のカードが開く

デモ

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

仕様

用途
カード
動作
WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

棚に並んだ9冊のどれかにカーソルを合わせると、その本が手前へ引き出され、上の端が少しこちらへ傾きます。引き出しきるころには、本の右に書名と著者、価格を載せたカードが開きます。背表紙の書名と著者名は、CanvasTexture に字を縦に並べて描きました。

どの本の上にカーソルがあるかは Raycaster で調べていますが、当てているのは本そのものではありません。本に当てたくなりますが、引き出された本は手前へ動いて傾くので、縁のあたりでカーソルが外れたり入ったりを繰り返し、本が震えてしまいます。そこで本の幅で、棚の奥から引き出した先までを覆う見えない直方体を置き、これを当たりにしました。本がどう動いても、当たりの形は変わりません。

引き出す動きは0.55秒の power2.inOut。1冊ごとにタイムラインを用意して play() と reverse() で行き来させ、離れるときだけ timeScale(2) で倍の速さで戻します。傾きはおよそ14°。回転の軸を本の下の手前の角に置いたので、傾けても本の底が棚にめり込みません。

canvas に描いた本はスクリーンリーダーに届かないので、同じ順に並べた本の一覧をリンクとして置き、見た目からは隠しています。Tab でリンクに移ると対応する本が引き出されてカードが開き、カードの縁が太くなってフォーカスを示します。タッチ端末では、最初のタップで引き出し、同じ本をもう一度タップするとページへ移ります。

引き出す距離と傾ける角度を変えると、動きの性格ががらりと変わります。距離を本の奥行きに近づけると棚から抜き取るような動きになり、角度を0にすれば、まっすぐ引き出すだけの落ち着いた見え方になります。

コード

HTML
<section class="shelf" aria-labelledby="shelf-heading">
  <div class="shelf__head">
    <p class="shelf__eyebrow">New Releases</p>
    <h2 class="shelf__heading" id="shelf-heading">今月の新刊</h2>
  </div>
  <!-- 本は canvas に描くので、読み上げからは外す。中身は下の一覧で伝える -->
  <canvas class="shelf__canvas" aria-hidden="true"></canvas>
  <!-- 棚の本と同じ順の一覧。見た目は canvas の本が受け持ち、Tab でたどるとその本が引き出される -->
  <ul class="shelf__books">
    <li><a href="/books/yama-no-daidokoro/"><span class="shelf__title">山の台所</span> <span class="shelf__meta">森田 結 1,980円</span></a></li>
    <li><a href="/books/kita-no-todai/"><span class="shelf__title">北の灯台</span> <span class="shelf__meta">青木 遥 1,760円</span></a></li>
    <li><a href="/books/hitori-no-jikan/"><span class="shelf__title">ひとりの時間</span> <span class="shelf__meta">小野寺 葵 1,650円</span></a></li>
    <li><a href="/books/umibe-no-shosai/"><span class="shelf__title">海辺の書斎</span> <span class="shelf__meta">佐伯 透 2,200円</span></a></li>
    <li><a href="/books/ame-no-hi-no-techo/"><span class="shelf__title">雨の日の手帖</span> <span class="shelf__meta">早瀬 湊 1,870円</span></a></li>
    <li><a href="/books/yoru-no-shokubutsuen/"><span class="shelf__title">夜の植物園</span> <span class="shelf__meta">秋山 凪 2,420円</span></a></li>
    <li><a href="/books/chiisana-tabi/"><span class="shelf__title">小さな旅の記録</span> <span class="shelf__meta">杉本 澄 1,980円</span></a></li>
    <li><a href="/books/saka-no-ue-no-kissaten/"><span class="shelf__title">坂の上の喫茶店</span> <span class="shelf__meta">長谷川 文 2,090円</span></a></li>
    <li><a href="/books/fuyu-no-kudamono/"><span class="shelf__title">冬の果物</span> <span class="shelf__meta">川村 実 1,760円</span></a></li>
  </ul>
  <!-- 引き出した本の横に表示する書名のカード。中身は一覧の文をそのまま入れる -->
  <p class="shelf__card" aria-hidden="true"><strong class="shelf__card-title"></strong><span class="shelf__card-meta"></span></p>
</section>

CSS
.shelf {
  /* 本の色はここで決める。script.js がこの変数を読んで、背表紙と表紙を塗る。空白で区切った順に、一覧の本へ当てる */
  --covers: #b9776e #5f84a6 #ece5d6 #6e9b78 #45596a #8c7fb3 #d7b76a #ae8a62 #c6d3dc;
  --spine-dark: #33302b; /* 明るい表紙の背表紙の文字 */
  --spine-light: #f4f0e8; /* 暗い表紙の背表紙の文字 */
  --pages: #f3eee3; /* 本の天と小口(紙の色) */
  --board: #d7c6a9; /* 棚板 */
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute-small: #6b6b6b;
  --line: rgba(38, 38, 38, 0.12);
  position: relative;
  height: clamp(420px, 56.25vw, 720px); /* 横長の画面では 16:9 */
  overflow: clip;
  background-color: var(--bg);
  color: var(--ink);
}

.shelf__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.shelf__head {
  position: absolute;
  top: clamp(24px, 7.5%, 96px);
  left: clamp(24px, 7.5%, 96px);
  z-index: 1;
}

.shelf__eyebrow {
  margin: 0;
  color: var(--mute-small);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  line-height: 1;
  text-transform: uppercase;
}

.shelf__heading {
  margin: 16px 0 0;
  font-size: clamp(28px, 3.5vw, 44px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.1;
  white-space: nowrap;
}

/* 一覧は見えなくして、読み上げと Tab の移動だけに使う */
.shelf__books {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.shelf__card {
  position: absolute;
  inset: 0 auto auto 0; /* 位置は script.js が transform で決める */
  z-index: 2;
  width: 200px;
  margin: 0;
  padding: 16px 18px;
  box-sizing: border-box;
  border-radius: 4px;
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
  clip-path: inset(0 100% 0 0); /* script.js が左から開く */
}

.shelf__card-title {
  display: block;
  font-size: 20px;
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.shelf__card-meta {
  display: block;
  margin-top: 8px;
  color: var(--mute-small);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.04em;
  line-height: 1.4;
}

/* 一覧は見えないので、Tab で本を選んでいる間は、カードの縁を太くしてフォーカスを示す(clip-path の外の outline は切られる) */
.shelf:has(.shelf__books a:focus-visible) .shelf__card { box-shadow: inset 0 0 0 2px var(--ink); }

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

const PULL = 0.95, TILT = 0.24, PULL_IN = 0.55; // 手前へ引き出す距離・上を手前へ傾ける角度(rad)・引き出しきるまでの秒数(戻るのは倍の速さ)
const FRONT = 0.95, GAP = 0.03; // 背表紙をそろえる位置と、本のすき間
const SIZES = [[2.55, 0.52, 1.72], [2.78, 0.46, 1.8], [2.4, 0.6, 1.66], [2.68, 0.5, 1.76], [2.86, 0.56, 1.84], [2.5, 0.48, 1.7], [2.62, 0.62, 1.74], [2.74, 0.54, 1.8], [2.44, 0.5, 1.68]]; // 高さ・厚み・奥行き
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 背表紙の画像。下から順にマーク・著者名・書名の場所を決め、書名は著者名の上に収まる大きさで縦に並べる
function spine(title, author, color, ink, font, w, h) {
  const canvas = Object.assign(document.createElement("canvas"), { width: Math.round(w * 220), height: Math.round(h * 220) });
  const g = canvas.getContext("2d"), W = canvas.width, H = canvas.height, name = [...author.replace(" ", "")];
  g.lang = "ja"; // ページに置かない canvas は文書の lang を継がないので、和文の書体を選ばせる
  g.fillStyle = color; g.fillRect(0, 0, W, H); g.fillStyle = ink; g.globalAlpha = 0.5; g.fillRect(14, 30, W - 28, 3); g.fillRect(14, H - 33, W - 28, 3); g.globalAlpha = 1;
  g.textAlign = "center"; g.textBaseline = "middle";
  const small = Math.min(28, W * 0.24), nameTop = H - 108 - (name.length - 1) * small * 1.1;
  const size = Math.min(52, W * 0.42, (nameTop - 110) / (title.length * 1.08));
  g.font = `700 ${size}px ${font}`; [...title].forEach((ch, i) => g.fillText(ch, W / 2, 70 + size * 0.6 + i * size * 1.08));
  g.font = `500 ${small}px ${font}`; name.forEach((ch, i) => g.fillText(ch, W / 2, nameTop + i * small * 1.1));
  g.beginPath(); g.arc(W / 2, H - 64, 8, 0, Math.PI * 2); g.fill();
  return Object.assign(new THREE.CanvasTexture(canvas), { colorSpace: THREE.SRGBColorSpace, anisotropy: 4 });
}
// 背表紙の字を描く前に、書体の読み込みを待つ
document.fonts.ready.then(() => document.querySelectorAll(".shelf").forEach((shelf) => {
  const style = getComputedStyle(shelf), css = (name) => style.getPropertyValue(name).trim(), covers = css("--covers").split(/\s+/);
  const links = [...shelf.querySelectorAll(".shelf__books a")], card = shelf.querySelector(".shelf__card");
  const renderer = new THREE.WebGLRenderer({ canvas: shelf.querySelector(".shelf__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setClearColor(css("--bg"), 1);
  renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap;
  const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(28, 16 / 9, 0.1, 100);
  const sun = new THREE.DirectionalLight("#ffffff", 1.7); // 左上の手前から照らし、本の影を棚板と奥の壁へ落とす
  sun.position.set(-3, 8, 10); sun.target.position.set(0.9, 1, 0); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);
  sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02; Object.assign(sun.shadow.camera, { left: -6, right: 6, top: 6, bottom: -6, far: 30 });
  const board = new THREE.Mesh(new THREE.BoxGeometry(6.2, 0.22, 2.5), new THREE.MeshStandardMaterial({ color: css("--board"), roughness: 0.75 }));
  const wall = new THREE.Mesh(new THREE.PlaneGeometry(30, 16), new THREE.ShadowMaterial({ opacity: 0.08 })); // 影だけを受ける奥の壁
  board.position.set(0.9, -0.11, -0.1); wall.position.set(0.9, 4, -1.3); board.receiveShadow = wall.receiveShadow = true;
  scene.add(sun, sun.target, board, wall, new THREE.HemisphereLight("#ffffff", "#cfc6b6", 1.9));
  // 本を左から詰めて並べる。当たりは本そのものではなく、本の幅で、棚の奥から引き出した先まで覆う見えない直方体で取る
  const pages = new THREE.MeshStandardMaterial({ color: css("--pages"), roughness: 0.9 }), hidden = new THREE.MeshBasicMaterial({ visible: false });
  const sizes = links.map((link, i) => SIZES[i % SIZES.length]); let left = 0.9 - (sizes.reduce((sum, [, w]) => sum + w, 0) + GAP * (links.length - 1)) / 2;
  const books = links.map((link, i) => {
    const [h, w, d] = sizes[i], x = left + w / 2, color = covers[i % covers.length]; left += w + GAP;
    const ink = new THREE.Color(color).getHSL({}).l > 0.6 ? css("--spine-dark") : css("--spine-light");
    const title = link.querySelector(".shelf__title").textContent, meta = link.querySelector(".shelf__meta").textContent;
    const cover = new THREE.MeshStandardMaterial({ color, roughness: 0.62 });
    const face = new THREE.MeshStandardMaterial({ map: spine(title, meta.split(" ")[0], color, ink, style.fontFamily, w, h), roughness: 0.62 });
    // 面の順は +x, -x, +y, -y, +z, -z。手前(+z)が背表紙、左右が表紙と裏表紙、天地と奥は紙の色。下の手前の角を原点にして、そこを軸に傾ける
    const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d).translate(0, h / 2, -d / 2), [cover, cover, pages, pages, face, pages]);
    const hit = new THREE.Mesh(new THREE.BoxGeometry(w + GAP, h + 0.6, d + PULL + 0.4), hidden); mesh.castShadow = mesh.receiveShadow = true;
    hit.position.set(x, h / 2, FRONT + (PULL + 0.4 - d) / 2); scene.add(mesh, hit);
    const state = { p: 0 }, book = { x, h, w, mesh, hit, title, meta, state };
    book.pull = gsap.timeline({ paused: true, onUpdate: () => draw() }).to(state, { p: 1, duration: PULL_IN, ease: "power2.inOut" });
    return book;
  });
  let current = -1, anchor = new THREE.Vector3();
  const draw = () => {
    for (const { x, mesh, state } of books) { mesh.position.set(x, 0, FRONT + PULL * state.p); mesh.rotation.x = TILT * state.p; }
    // カードは、いちばん引き出されている本の右に、引き出しきるころに開く
    const top = books.reduce((a, b) => (b.state.p > a.state.p ? b : a)), k = gsap.utils.clamp(0, 1, (top.state.p - 0.6) / 0.4);
    if (k > 0) {
      card.querySelector(".shelf__card-title").textContent = top.title; card.querySelector(".shelf__card-meta").textContent = top.meta;
      anchor.set(top.x + top.w / 2, top.h * 0.62, FRONT + PULL + 0.35).project(camera);
      const x = Math.min(((anchor.x + 1) / 2) * shelf.clientWidth + 14, shelf.clientWidth - card.offsetWidth - 8), y = ((1 - anchor.y) / 2) * shelf.clientHeight - 48;
      gsap.set(card, { x: Math.round(x - 12 * (1 - k)), y: Math.round(y), clipPath: `inset(0% ${(1 - k) * 100}% 0% 0%)` });
    } else gsap.set(card, { clipPath: "inset(0% 100% 0% 0%)" });
    renderer.render(scene, camera);
  };
  const hover = (index) => { if (index === current) return;
    books[current]?.pull.timeScale(reduceMotion ? 20 : 2).reverse(); // 戻るのは倍の速さ。動きを減らす設定では、ほぼ一瞬で切り替える
    books[index]?.pull.timeScale(reduceMotion ? 20 : 1).play();
    current = index; shelf.style.cursor = index >= 0 ? "pointer" : "";
  };
  const raycaster = new THREE.Raycaster(), pointer = new THREE.Vector2(), hits = books.map((b) => b.hit);
  const bookAt = (e) => {
    const r = shelf.getBoundingClientRect(); raycaster.setFromCamera(pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera);
    const hit = raycaster.intersectObjects(hits, false)[0];
    return hit ? hits.indexOf(hit.object) : -1;
  };
  shelf.addEventListener("pointermove", (e) => e.pointerType !== "touch" && hover(bookAt(e)));
  shelf.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && hover(-1));
  // マウスは押した本のページへ移る。タッチは 1 回目のタップで引き出し、引き出した本をもう一度タップしたら移る
  shelf.addEventListener("click", (e) => {
    const index = bookAt(e);
    if (index >= 0 && (e.pointerType !== "touch" || index === current)) links[index].click();
    else if (e.target.closest(".shelf__canvas")) hover(index);
  });
  links.forEach((link, i) => { link.addEventListener("focus", () => hover(i)); link.addEventListener("blur", () => hover(-1)); });
  const sizer = new ResizeObserver(() => {
    const aspect = (camera.aspect = shelf.clientWidth / shelf.clientHeight), narrow = aspect < 1.2; // 狭い画面では、棚を中央に置いて幅いっぱいに寄せる
    camera.zoom = Math.min(1, (aspect / (16 / 9)) * (narrow ? 1.7 : 1)); camera.position.set(1.9, 2.6, 13); camera.lookAt(narrow ? 0.9 : 0.5, narrow ? 1.6 : 1.15, 0);
    camera.updateProjectionMatrix(); camera.updateMatrixWorld();
    renderer.setSize(shelf.clientWidth, shelf.clientHeight, false); draw(); // 大きさは CSS が決めるので、style は書き換えない
  });
  sizer.observe(shelf);
  shelf.destroy = () => { // PJAX でページを差し替えて要素ごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    sizer.disconnect(); books.forEach((b) => b.pull.kill());
    scene.traverse((o) => { if (!o.isMesh) return; o.geometry.dispose(); [o.material].flat().forEach((m) => { m.map?.dispose(); m.dispose(); }); });
    renderer.dispose(); renderer.forceContextLoss();
  };
}));

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

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