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

立体の棒グラフが順に伸び、カメラが斜め上から回る

デモ

仕様

用途
グラフ
動作
DOM+WebGL
種類
シーン
使うもの
three/addons/geometries/RoundedBoxGeometry.js・three/addons/environments/RoomEnvironment.js
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

12 か月 × 4 地域の棒を、角の丸い箱(RoundedBoxGeometry)の InstancedMesh 1 つで描きます。高さはインスタンスごとの属性で渡し、頂点シェーダーで箱の上半分の頂点だけを持ち上げます。scale で縦に伸ばすと角の丸みまで縦に伸びてしまうので、この形にしています。影を描く材質にも同じ変形を入れ、伸びた形の影を台に落とします。

棒は 1 本 1 秒の power3.out で伸び、隣の月とは 0.05 秒、奥の地域へは 0.08 秒ずつずらして、1 月から順に、同じ月の中では手前の地域から立ち上がります。各地域の最後の月には値の札を付け、その棒が伸び始めて 0.3 秒後に左から開き、値を数えながら先端についていきます。カメラは斜め上から見下ろし、8 秒で往復するようにゆっくり回り込みます。回り込む量を sin² で決めているので、周期の始めと終わりで向きがそろいます。

札は DOM で重ね、棒の先端の 3D の位置を project で画面の座標に直して、整数 px に置きます。月と地域の名前は台の上面に canvas で描いて貼り、寝た面の字がにじまないよう、テクスチャの異方性フィルタを最大にします。canvas の文字は、lang を渡さないと和文だけページと違う書体(明朝など)で描かれることがあります。高さをシェーダーで変えると境界の球が実際より小さくなり、画面の端で棒が消えるので、カリングも切っておきます。

値は HTML の表が正で、スクリプトは表から棒・名前・札を組み、表は見えなくして読み上げ用に残します。数えている途中の合計は読み上げに渡さず、最終の値を添えます。動きを減らす設定では伸びきった 1 枚だけを描き、画面の外では回り込みを止めます。色は CSS 変数で、奥の地域ほど濃く、手前ほど淡い 4 色にし、隣り合う列は色相も変えています。照明と映り込みで棒は 2 倍近く明るく描かれるので、材質には色を 0.55 倍に暗くして渡し、光の当たる面が凡例の色に近く見えるようにしています。淡い列は地との対比が低いので、凡例と値の札を必ず添えてください。年次報告・投資家向け・地域別の実績のセクションに向いています。

コード

HTML
<!-- 値はこの表が正。script.js が表から立体の棒・台の上の名前・値の札を組む(表は読み上げ用に残す) -->
<figure class="bars3d" aria-labelledby="bars3d-title">
  <figcaption class="bars3d__head">
    <span class="bars3d__eyebrow">Annual report · 2026</span>
    <span class="bars3d__title" id="bars3d-title">地域別の売上</span>
    <span class="bars3d__note">月ごとの売上・単位:万円</span>
  </figcaption>
  <!-- 凡例の色は style.css の --series-1〜4(表の行の順と同じ) -->
  <ul class="bars3d__legend" aria-hidden="true">
    <li style="--color: var(--series-1)">北米</li>
    <li style="--color: var(--series-2)">欧州</li>
    <li style="--color: var(--series-3)">アジア太平洋</li>
    <li style="--color: var(--series-4)">中南米</li>
  </ul>
  <p class="bars3d__sum">
    <span class="bars3d__sum-label">売上の合計</span>
    <span class="bars3d__sum-value"><span class="bars3d__total">24,950</span><small>万円</small></span>
  </p>
  <!-- 表と同じ値を描くだけなので、読み上げからは外す -->
  <div class="bars3d__stage" aria-hidden="true"><canvas></canvas></div>
  <table class="bars3d__table">
    <caption>地域別・月ごとの売上(単位:万円)</caption>
    <thead>
      <tr><th scope="col">地域</th><th scope="col">1月</th><th scope="col">2月</th><th scope="col">3月</th><th scope="col">4月</th><th scope="col">5月</th><th scope="col">6月</th><th scope="col">7月</th><th scope="col">8月</th><th scope="col">9月</th><th scope="col">10月</th><th scope="col">11月</th><th scope="col">12月</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">北米</th><td>620</td><td>580</td><td>660</td><td>710</td><td>690</td><td>760</td><td>820</td><td>790</td><td>850</td><td>910</td><td>880</td><td>960</td></tr>
      <tr><th scope="row">欧州</th><td>440</td><td>410</td><td>470</td><td>520</td><td>550</td><td>580</td><td>540</td><td>510</td><td>600</td><td>640</td><td>670</td><td>720</td></tr>
      <tr><th scope="row">アジア太平洋</th><td>300</td><td>330</td><td>370</td><td>350</td><td>410</td><td>450</td><td>490</td><td>530</td><td>510</td><td>570</td><td>620</td><td>680</td></tr>
      <tr><th scope="row">中南米</th><td>180</td><td>210</td><td>190</td><td>240</td><td>270</td><td>260</td><td>300</td><td>330</td><td>310</td><td>360</td><td>390</td><td>420</td></tr>
    </tbody>
  </table>
</figure>

CSS
.bars3d {
  /* 色はここで決める。script.js がこの変数を読んで、台・名前・棒・札の色にする */
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgb(38 38 38 / 0.12);
  --ground: #d6d3cc; /* 半球光の地面側。地に合わせた無彩色寄り */
  --series-1: #475c6a; /* 棒の色。表の行の順(奥ほど濃く、手前ほど淡く) */
  --series-2: #ba7069;
  --series-3: #93b2c9;
  --series-4: #e5cfb3;
  /* 見えなくした表が、狭い画面で横にはみ出さないよう切る */
  position: relative;
  overflow: clip;
  display: grid;
  grid-template-columns: 1fr auto;
  align-content: start;
  aspect-ratio: 16 / 9;
  margin: 0;
  padding: 3.75% 7.5%; /* 幅 1280px のとき上 48px・左右 96px */
  /* 文字は枠の幅に対する比(cqw)で決め、どの幅でも立体との位置関係をそろえる */
  container-type: inline-size;
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* 見出し・凡例・合計は、立体の上に重ねる */
.bars3d__head,
.bars3d__legend,
.bars3d__sum { position: relative; z-index: 1; }
.bars3d__head { display: grid; justify-items: start; }

.bars3d__eyebrow { color: var(--mute-small); font-size: max(11px, 0.94cqw); font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; }
.bars3d__title { margin-top: 1.25cqw; font-size: max(28px, 3.44cqw); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.bars3d__note { margin-top: 0.63cqw; color: var(--mute); font-size: max(13px, 1.25cqw); line-height: 1.5; letter-spacing: 0.04em; }

.bars3d__legend {
  grid-column: 1;
  display: flex;
  flex-wrap: wrap;
  gap: 8px 1.88cqw;
  margin: 1.25cqw 0 0;
  padding: 0;
  list-style: none;
  font-size: max(12px, 1.02cqw);
  line-height: 1;
  letter-spacing: 0.04em;
}
.bars3d__legend li { display: flex; align-items: center; gap: 0.6em; }
.bars3d__legend li::before { content: ""; width: 0.78em; aspect-ratio: 1; border-radius: 2px; background: var(--color); } /* 色は markup.html の --color */

/* 合計は右上。数字と単位は 1 行のままベースラインでそろえる */
.bars3d__sum { grid-column: 2; grid-row: 1; display: grid; justify-items: end; margin: 0; }
.bars3d__sum-label { color: var(--mute-small); font-size: max(12px, 1.1cqw); line-height: 1; letter-spacing: 0.04em; }
.bars3d__sum-value { margin-top: 1.25cqw; font-size: max(28px, 3.44cqw); font-weight: 800; line-height: 1; letter-spacing: -0.02em; white-space: nowrap; }
.bars3d__sum-value small { margin-left: 2px; font-size: 45%; font-weight: 700; letter-spacing: 0.04em; }

/* 立体は枠いっぱいに敷く。札もこの中に置く */
.bars3d__stage { position: absolute; inset: 0; }
.bars3d__stage canvas { display: block; width: 100%; height: 100%; }

/* 値の札。位置と開き具合は script.js が入れる(左から clip-path で開く) */
.bars3d__tag {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.46em;
  width: max(40px, 5cqw);
  height: max(16px, 2.19cqw);
  border-radius: 999px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: max(10px, 1.02cqw);
  font-weight: 700;
  line-height: 1;
  clip-path: inset(0 100% 0 0 round 999px);
}
.bars3d__tag::before { content: ""; width: 0.54em; aspect-ratio: 1; border-radius: 50%; background: var(--color); }

/* 組んだあとの表と合計の値は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.bars3d.is-ready .bars3d__table,
.bars3d__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 狭い画面では、重ねると見出しが奥の棒にかかる。見出しの下に 4:3 の立体を置く */
@media (max-width: 900px) {
  .bars3d { grid-template-columns: 1fr; gap: 16px; aspect-ratio: auto; padding: 32px 16px; }
  .bars3d__sum { grid-column: 1; grid-row: auto; justify-items: start; }
  .bars3d__stage { position: relative; aspect-ratio: 4 / 3; margin: 0 -16px; }
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";

const MAX = 1000, HEIGHT = 3.2; // 棒の高さ HEIGHT(ワールドの単位)に当たる値
const BAR = 0.5, RADIUS = 0.07, PITCH_X = 0.8, PITCH_Z = 1.15; // 棒の太さ・角の丸み・月の間隔・地域の間隔
const GROW = 1, EACH_MONTH = 0.05, EACH_ROW = 0.08, TAG_LAG = 0.3; // 1 本が伸びる秒数・隣の月とのずれ・奥の地域へのずれ・札が開くまで
const AZIMUTH = 0.36, SWING = 0.26, ELEVATION = 0.48, DISTANCE = 20, PERIOD = 8; // カメラの向き・回り込む量・見下ろす角度・距離・1 往復の秒数
const TARGET = new THREE.Vector3(1.25, 1.45, 0.45); // カメラが見る点。台の右手前の角と地域の名前が収まる位置
const MIN_H = RADIUS * 2 + 0.01, SHADE = 0.55; // 伸びる前の棒(角の丸いタイル)・棒の色に掛ける倍率(照明で 2 倍近く明るく描かれ、そのままだとパステルに見える)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 棒の頂点のうち、上半分だけを aHeight − 1 持ち上げる。scale.y で伸ばすと、角の丸みまで縦に伸びる
const stretch = (shader) => (shader.vertexShader = shader.vertexShader.replace("#include <common>", "#include <common>\nattribute float aHeight;")
  .replace("#include <begin_vertex>", "#include <begin_vertex>\ntransformed.y += step(0.5, position.y) * (aHeight - 1.0);"));
await document.fonts.ready; // 台の上の名前を、ページの書体で canvas に描くため

document.querySelectorAll(".bars3d").forEach((chart) => {
  const css = (name) => getComputedStyle(chart).getPropertyValue(name).trim();
  const stage = chart.querySelector(".bars3d__stage"), total = chart.querySelector(".bars3d__total");
  const months = [...chart.querySelectorAll("thead th")].slice(1).map((th) => th.textContent);
  const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => ({ name: tr.cells[0].textContent, values: [...tr.cells].slice(1).map((td) => Number.parseFloat(td.textContent.replace(/,/g, ""))) }));
  const xOf = (col) => (col - (months.length - 1) / 2) * PITCH_X, zOf = (row) => (row - (rows.length - 1) / 2) * PITCH_Z; // 表の上の行が奥
  const HALF_X = xOf(months.length - 1) + BAR / 2, HALF_Z = zOf(rows.length - 1) + BAR / 2;
  const plate = { x0: -HALF_X - 0.6, x1: HALF_X + 2.6, z0: -HALF_Z - 0.55, z1: HALF_Z + 1.25 }; // 右に地域、手前に月の名前を置く余白
  // 1 月から順に、同じ月の中では手前の地域から伸ばす。v は今の値
  const bars = rows.flatMap((r, row) => r.values.map((value, col) => ({ row, col, value, v: 0, delay: col * EACH_MONTH + (rows.length - 1 - row) * EACH_ROW })));
  const renderer = new THREE.WebGLRenderer({ canvas: stage.querySelector("canvas"), antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  Object.assign(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), pmrem = new THREE.PMREMGenerator(renderer), room = new RoomEnvironment();
  const env = pmrem.fromScene(room, 0.04); scene.environment = env.texture; room.dispose(); pmrem.dispose(); // 映り込み用の部屋の光
  const key = new THREE.DirectionalLight("#ffffff", 2.1); key.position.set(6, 11, 7); key.castShadow = true; key.shadow.mapSize.set(1024, 1024);
  Object.assign(key.shadow, { radius: 3, bias: -0.0005, normalBias: 0.02 }); Object.assign(key.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 40 });

  // 台の上面に貼る絵(地域ごとの帯・月と地域の名前)。名前は表の見出しから描く。canvas の上端が台の奥
  const k = 2048 / (plate.x1 - plate.x0), canvas = Object.assign(document.createElement("canvas"), { width: 2048, height: Math.round((plate.z1 - plate.z0) * k) });
  const g = canvas.getContext("2d"), X = (x) => (x - plate.x0) * k, Z = (z) => (z - plate.z0) * k;
  rows.forEach((_, row) => { g.fillStyle = css("--bg"); g.beginPath(); g.roundRect(X(-HALF_X - 0.18), Z(zOf(row) - BAR / 2 - 0.16), (HALF_X * 2 + 0.36) * k, (BAR + 0.32) * k, 0.2 * k); g.fill(); });
  // 書体はページと同じもの。lang を渡さないと、和文がページと違う書体(明朝など)で描かれることがある
  Object.assign(g, { font: `600 50px ${getComputedStyle(chart).fontFamily}`, lang: document.documentElement.lang, textBaseline: "middle", letterSpacing: "2px", fillStyle: css("--mute-small"), textAlign: "center" });
  months.forEach((month, col) => g.fillText(month, X(xOf(col)), Z(HALF_Z + 0.6)));
  Object.assign(g, { fillStyle: css("--ink"), textAlign: "left" }); rows.forEach((r, row) => g.fillText(r.name, X(HALF_X + 0.34), Z(zOf(row))));
  const texture = Object.assign(new THREE.CanvasTexture(canvas), { colorSpace: THREE.SRGBColorSpace, anisotropy: renderer.capabilities.getMaxAnisotropy() }); // 寝た面の字をにじませない
  const top = new THREE.MeshStandardMaterial({ map: texture, transparent: true, roughness: 0.9, envMapIntensity: 0.4 }), labels = new THREE.Mesh(new THREE.PlaneGeometry(plate.x1 - plate.x0, plate.z1 - plate.z0), top);
  const base = new THREE.Mesh(new RoundedBoxGeometry(plate.x1 - plate.x0, 0.26, plate.z1 - plate.z0, 3, 0.13), new THREE.MeshStandardMaterial({ color: css("--surface"), roughness: 0.85, envMapIntensity: 0.4 }));
  base.position.set((plate.x0 + plate.x1) / 2, -0.13, (plate.z0 + plate.z1) / 2); labels.position.set(base.position.x, 0.004, base.position.z);
  labels.rotation.x = -Math.PI / 2; base.receiveShadow = labels.receiveShadow = true;

  // 棒は 1 つの InstancedMesh。高さはインスタンスごとの属性 aHeight で渡す(底を y = 0 に移した高さ 1 の箱)
  const geometry = new RoundedBoxGeometry(BAR, 1, BAR, 2, RADIUS).translate(0, 0.5, 0);
  const heights = new THREE.InstancedBufferAttribute(new Float32Array(bars.length).fill(MIN_H), 1).setUsage(THREE.DynamicDrawUsage); geometry.setAttribute("aHeight", heights);
  const material = new THREE.MeshStandardMaterial({ roughness: 0.42, envMapIntensity: 0.5 }), depth = new THREE.MeshDepthMaterial();
  material.onBeforeCompile = depth.onBeforeCompile = stretch; // 影を描く材質にも同じ変形を入れ、伸びた形の影を落とす
  // 高さはシェーダーで変えるので、境界の球が実際より小さい。カリングで消えないようにする
  const mesh = Object.assign(new THREE.InstancedMesh(geometry, material, bars.length), { customDepthMaterial: depth, castShadow: true, receiveShadow: true, frustumCulled: false });
  bars.forEach((b, i) => { mesh.setMatrixAt(i, new THREE.Matrix4().makeTranslation(xOf(b.col), 0, zOf(b.row))); mesh.setColorAt(i, new THREE.Color(css(`--series-${b.row + 1}`)).multiplyScalar(SHADE)); });
  scene.add(key, new THREE.HemisphereLight("#ffffff", css("--ground"), 1.1), base, labels, mesh);

  // 値の札(各地域の最後の月)。手前の地域から順に開き、値を数えながら先端についていく
  const tags = rows.map((_, row) => ({ el: stage.appendChild(Object.assign(document.createElement("span"), { className: "bars3d__tag" })), bar: bars[(row + 1) * months.length - 1], open: 0 }));
  tags.forEach(({ el, bar }) => el.style.setProperty("--color", `var(--series-${bar.row + 1})`)); // 札の点の色(CSS 変数を指す)
  const tl = gsap.timeline({ paused: true }).fromTo(bars, { v: 0 }, { v: (i) => bars[i].value, duration: GROW, ease: "power3.out", stagger: (i) => bars[i].delay });
  tags.forEach((tag) => tl.fromTo(tag, { open: 0 }, { open: 1, duration: 0.4, ease: "power2.out" }, tag.bar.delay + TAG_LAG));
  const anchor = new THREE.Vector3(), label = (v) => Math.round(v).toLocaleString("ja-JP"); let now = 0;
  const draw = (time) => {
    now = time; bars.forEach((b, i) => heights.setX(i, Math.max(MIN_H, (b.v / MAX) * HEIGHT))); heights.needsUpdate = true;
    total.textContent = label(bars.reduce((sum, b) => sum + b.v, 0));
    // カメラ: 斜め上から、ゆっくり回り込んで戻る(sin² で、周期の始めと終わりが同じ向き)
    const azimuth = AZIMUTH + SWING * Math.sin((Math.PI * time) / PERIOD) ** 2;
    camera.position.set(Math.sin(azimuth), 0, Math.cos(azimuth)).multiplyScalar(DISTANCE * Math.cos(ELEVATION)).add(TARGET).y += DISTANCE * Math.sin(ELEVATION);
    camera.lookAt(TARGET); camera.updateMatrixWorld();
    for (const { el, bar, open } of tags) { // 棒の先端の少し上を画面へ投影し、整数 px に置く
      anchor.set(xOf(bar.col), Math.max(MIN_H, (bar.v / MAX) * HEIGHT) + 0.14, zOf(bar.row)).project(camera);
      const x = Math.round(((anchor.x + 1) / 2) * stage.clientWidth - el.offsetWidth / 2), y = Math.round(((1 - anchor.y) / 2) * stage.clientHeight - el.offsetHeight);
      Object.assign(el.style, { transform: `translate(${x}px, ${y}px)`, clipPath: `inset(0 ${(1 - Math.min(1, open)) * 100}% 0 0 round 999px)` });
      el.textContent = label(bar.v);
    }
    renderer.render(scene, camera);
  };
  // 数えている途中の合計は読み上げに渡さず、見えない最終の値を添える
  total.setAttribute("aria-hidden", "true"); total.after(Object.assign(document.createElement("span"), { className: "bars3d__sr", textContent: label(bars.reduce((sum, b) => sum + b.value, 0)) }));
  if (reduceMotion) tl.progress(1); chart.classList.add("is-ready"); // 動きを減らす設定では、伸びきった 1 枚だけ描いて止める
  const sizes = new ResizeObserver(() => { // 4:3 より縦長の枠では引いて、横に収める
    Object.assign(camera, { aspect: stage.clientWidth / stage.clientHeight, zoom: Math.min(1, stage.clientWidth / stage.clientHeight / (4 / 3)) }).updateProjectionMatrix();
    renderer.setSize(stage.clientWidth, stage.clientHeight, false); draw(now); // 大きさは CSS が決めるので、style は書き換えない
  });
  // 枠の上端が画面の 7 割まで来たら 1 回だけ伸ばす。回り込みは画面の中にある間だけ回す
  let start = null;
  const view = new IntersectionObserver(([e]) => {
    if (e.isIntersecting && !reduceMotion) tl.play();
    renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw((ms - (start ??= ms)) / 1000) : null);
  }, { rootMargin: "0px 0px -30%" });
  sizes.observe(stage); view.observe(chart);
  chart.destroy = () => { // PJAX で捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect(); tl.kill();
    [geometry, material, depth, base.geometry, base.material, labels.geometry, top, texture, env, mesh].forEach((item) => item.dispose());
    renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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