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

スクロールでヒーローのカメラが 3D の道を進む

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
ヒーロー
動作
DOM+WebGL
種類
シーン
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

章の見出しを縦に並べたページの奥で、Three.js のカメラが光る道に沿って進みます。道は CatmullRomCurve3 の曲線で、スクロールの進み具合(0〜1)を ScrollTrigger の scrub: true で受け取り、カメラを曲線上の点に立たせて、道の長さの 3.5% 先の点を見させます。道の終わりでも先を見られるよう、進むのは道の 9 割までです。

道は曲線から作った平らな帯(左右の端は、接線に直角な向きへ幅の半分ずつ)で、門は半円のトーラスを曲線の点に置き、道の向きへ向けて立てます。石の柱と道の灯りは InstancedMesh でまとめて描き、石は色違いにしています。空は CSS のグラデーションで、canvas を透明にして重ね、霧の色を地平の色にそろえて、奥の道や石を空に溶かします。

全面の 3D の上に黒の幕(左 0.6 → 右 0.3)を動かさずに重ね、白い文字を載せます。左右に振り分けた章の説明は、幕と同じ半透明の黒の板に載せ、背景の明るい所でも読めるようにしています。章の列は画面に固定した舞台(position: sticky)の中で 1 画面ずつ送り、固定したナビの下(上から 96〜176px)で mask-image によって消すので、章の文字がロゴと重なりません。

自前で作った道の面は、上から見て反時計回りに頂点を並べます。逆にすると面が裏向きになり、既定の FrontSide では道が写りません。描くのは進み具合と画面の大きさが変わったときだけなので、止まっている間は描画しません。動きを減らす設定では、カメラを出発点に止めたまま、章だけをスクロールで送ります。

道の形(曲線の点)・門の位置・目の高さ・先を見る距離・進む割合・空と霧の色で調整できます。石の並びは種つきの乱数で決めているので、種を変えると並びが変わります。物語仕立てのランディングや、製品・ブランドの紹介に向いています。

コード

HTML
<!-- 章を縦に並べたページ。画面に固定した舞台の奥で、スクロールに合わせてカメラが光る道を進む -->
<section class="journey" aria-labelledby="journey-title">
  <div class="journey__stage">
    <!-- 奥の 3D は飾りなので、読み上げからは外す -->
    <canvas class="journey__canvas" aria-hidden="true"></canvas>

    <header class="journey__bar">
      <a class="journey__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
      <!-- 開閉は、サイトのメニューにつなぐ -->
      <button class="journey__menu" type="button" aria-expanded="false">メニュー</button>
    </header>

    <!-- 進み具合の線 -->
    <div class="journey__progress" aria-hidden="true"><span></span></div>

    <!-- 章の列。script.js がスクロールに合わせて 1 画面ずつ上へ送る -->
    <div class="journey__window">
      <div class="journey__track">
        <div class="journey__chapter journey__chapter--center">
          <div>
            <p class="journey__hint">スクロールして出発</p>
            <h1 class="journey__title" id="journey-title">夕暮れの道を歩く。</h1>
            <p class="journey__text">ここに説明文が入ります。</p>
          </div>
        </div>
        <div class="journey__chapter">
          <div class="journey__card">
            <p class="journey__label">Chapter 01</p>
            <h2 class="journey__heading">アーチを<wbr>くぐり、</h2>
            <p class="journey__text">ここに説明文が入ります。</p>
          </div>
        </div>
        <div class="journey__chapter journey__chapter--right">
          <div class="journey__card">
            <p class="journey__label">Chapter 02</p>
            <h2 class="journey__heading">ビル群を<wbr>通り抜けて、</h2>
            <p class="journey__text">ここに説明文が入ります。</p>
          </div>
        </div>
        <div class="journey__chapter">
          <div class="journey__card">
            <p class="journey__label">Chapter 03</p>
            <h2 class="journey__heading">夕日の<wbr>彼方へ。</h2>
            <p class="journey__text">ここに説明文が入ります。</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>

CSS
.journey {
  /* 3D の色。script.js がこの変数を読んでシーンへ渡す */
  --horizon: #d2aa98; /* 地平の色。空のグラデーションと霧にも使い、奥の道や石を空に溶かす */
  --ground: #3e4a55;
  --road: #efe4d0;
  --lamp: #e6d3b3;
  --arch-a: #d29b97;
  --arch-b: #f2ebdf;
  --stones: #688297 #877fa1 #b27771 #4a5b66; /* 石の柱の色を順に使う */
  --sun: #f4ebdd;
  --halo: #e6cdb6;
  --sky-light: #ede0d6; /* 空からの照り返し */
  --ground-light: #2c3740; /* 地面からの照り返し */
  --sunlight: #e8c9b0; /* 奥の太陽の光 */
  --backlight: #b0a5ca; /* 背中からのラベンダーの光 */
  --ink: #ffffff;
  --soft: #eeece6;
  --mute: #dedcd6;
  --line: rgba(255, 255, 255, 0.2);
  --gutter: clamp(24px, 7.5vw, 96px);
  position: relative;
  height: calc(var(--chapters, 4) * 100svh); /* 章の数だけスクロールする長さ(script.js が章を数えて入れる) */
  color: var(--ink);
}

/* 画面に固定する舞台。重なり: 空(地)→ 3D → 黒の幕(::after)→ 文字 */
.journey__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  background: linear-gradient(180deg, #3f4e5a 0%, #877fa1 26%, #d29b97 42%, var(--horizon) 52%, var(--horizon) 100%);
}
.journey__canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.journey__stage::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3)); }
.journey__bar, .journey__progress, .journey__window { position: absolute; z-index: 1; }

.journey__bar {
  top: 32px; left: var(--gutter); right: var(--gutter); height: 48px;
  display: flex; justify-content: space-between; align-items: center; gap: 24px;
  font-size: 14px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap;
}
.journey__logo { color: inherit; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; text-decoration: none; }
.journey__menu { padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; cursor: pointer; }
.journey__bar :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.journey__progress { right: 48px; top: 30%; height: 40%; width: 2px; border-radius: 2px; background: var(--line); }
.journey__progress span { display: block; height: 0; border-radius: 2px; background: var(--ink); }

/* 章の列は、固定したナビの下(上から 96〜176px)で消えていくようにする。文字がロゴと重ならない */
.journey__window {
  inset: 0;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(180deg, transparent 96px, #000 176px);
  mask-image: linear-gradient(180deg, transparent 96px, #000 176px);
}
.journey__chapter { box-sizing: border-box; height: 100svh; display: flex; align-items: center; padding: 0 var(--gutter); }
.journey__chapter--center { justify-content: center; text-align: center; }
.journey__chapter--right { justify-content: flex-end; }

.journey__hint { margin: 0; color: var(--mute); font-size: 13px; font-weight: 600; line-height: 1; letter-spacing: 0.06em; }
.journey__title { margin: 24px 0 0; font-size: clamp(40px, 5.625vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; word-break: auto-phrase; }
.journey__text { margin: 24px 0 0; color: var(--soft); font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 左右に振り分けた章の説明は、幕と同じ半透明の黒の板に載せる(絵の明るい所でも読める) */
.journey__card {
  box-sizing: border-box; width: 512px; max-width: 100%; padding: 32px; border-radius: 4px;
  background: rgba(0, 0, 0, 0.4); box-shadow: inset 0 0 0 1px var(--line);
}
.journey__label { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; }
.journey__heading { margin: 16px 0 0; font-size: clamp(32px, 3.4375vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.journey__card .journey__text { margin-top: 16px; }

@media (max-width: 720px) {
  .journey__stage::after { background: rgba(0, 0, 0, 0.45); }
  .journey__progress { right: 12px; }
  .journey__text br { display: none; }
}

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

gsap.registerPlugin(ScrollTrigger);

const PATH = [[0, 12], [1.2, 3], [-1.8, -7], [2.4, -17], [-2.2, -27], [1.6, -37], [-0.8, -47], [0.6, -57], [0, -70]]; // 道の通る点 [x, z]。奥(z のマイナス)へ進む
const ARCHES = [0.08, 0.18, 0.28, 0.38, 0.48, 0.58, 0.68, 0.78]; // 門を立てる位置(道の長さに対する比)
const TRAVEL = 0.9; // 道のどこまで進むか(終わりでも先を見られるよう、少し残す)
const LOOK_AHEAD = 0.035; // 道のどれだけ先を見るか
const EYE = 1.5; // 目の高さ
const ROAD = 1.4; // 道の幅
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 石の並びを毎回同じにする、種つきの小さな乱数(mulberry32)。種を変えると並びが変わる
let seed = 0x4e66b573;
const random = () => {
  seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

document.querySelectorAll(".journey").forEach((journey) => {
  const css = (name) => getComputedStyle(journey).getPropertyValue(name).trim();
  const stage = journey.querySelector(".journey__stage");
  // canvas は透明にして、CSS の空のグラデーションの上に重ねる
  const renderer = new THREE.WebGLRenderer({ canvas: journey.querySelector(".journey__canvas"), antialias: true, alpha: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene(); scene.fog = new THREE.Fog(css("--horizon"), 6, 58); // 霧を空の地平の色にそろえ、奥の道や石を空に溶かす
  const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 220);
  const curve = new THREE.CatmullRomCurve3(PATH.map(([x, z]) => new THREE.Vector3(x, 0, z)));
  const sunLight = new THREE.DirectionalLight(css("--sunlight"), 2.6), backLight = new THREE.DirectionalLight(css("--backlight"), 1);
  sunLight.position.set(-4, 7, -30); backLight.position.set(3, 5, 20);
  scene.add(new THREE.HemisphereLight(css("--sky-light"), css("--ground-light"), 2), sunLight, backLight);
  const add = (geometry, material) => scene.add(new THREE.Mesh(geometry, material)).children.at(-1);
  // 道の u(0〜1)の点から、道の向きに ahead だけ先へ、道に直角に off だけ横へずれた地面の点
  const at = (u, off = 0, ahead = 0) => {
    const p = curve.getPointAt(u), t = curve.getTangentAt(u);
    return p.addScaledVector(t, ahead).add(new THREE.Vector3(-t.z * off, 0, t.x * off));
  };

  add(new THREE.PlaneGeometry(320, 320).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: css("--ground"), roughness: 1 })).position.z = -60;
  // 光る道: 曲線に沿った平らな帯。終わりの先も同じ向きのまま 90 延ばし、霧に溶かして地平まで続けて見せる
  const positions = [], index = [];
  for (let i = 0; i <= 248; i++) {
    for (const side of [-1, 1]) { const p = at(Math.min(i / 240, 1), (side * ROAD) / 2, Math.max(i - 240, 0) * 11.25); positions.push(p.x, 0.01, p.z); }
    if (i < 248) index.push(i * 2, i * 2 + 1, i * 2 + 2, i * 2 + 1, i * 2 + 3, i * 2 + 2); // 上から見て反時計回り(逆だと裏向きで消える)
  }
  add(new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)).setIndex(index), new THREE.MeshBasicMaterial({ color: css("--road") }));

  // 道の両端の灯りと、両側の石の柱は、InstancedMesh で 1 回ずつ描く
  const m = new THREE.Matrix4(), colors = css("--stones").split(/\s+/);
  const lamps = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.07, 1), new THREE.MeshBasicMaterial({ color: css("--lamp") }), 120);
  for (let i = 0; i < 120; i++) { const p = at(Math.floor(i / 2) / 59, (i % 2 ? 1 : -1) * (ROAD / 2 + 0.35)); lamps.setMatrixAt(i, m.makeTranslation(p.x, 0.1, p.z)); }
  const stones = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ roughness: 0.9, flatShading: true }), 70);
  for (let i = 0; i < 70; i++) {
    const p = at(random(), (i % 2 ? -1 : 1) * (3.6 + random() * 12)), h = 1.2 + random() * 4.5;
    const size = new THREE.Vector3(0.6 + random() * 1.1, h, 0.6 + random() * 1.1);
    stones.setMatrixAt(i, m.makeRotationY(random() * Math.PI).scale(size).setPosition(p.x, h / 2, p.z));
    stones.setColorAt(i, new THREE.Color(colors[i % colors.length]));
  }
  scene.add(lamps, stones);
  // 門: 半分のトーラスを道の点に置き、道の向きへ向けて、道に直角に立てる
  const archGeometry = new THREE.TorusGeometry(2.3, 0.13, 12, 48, Math.PI);
  const archMaterials = ["--arch-a", "--arch-b"].map((name) => new THREE.MeshStandardMaterial({ color: css(name), roughness: 0.45 }));
  ARCHES.forEach((u, i) => {
    const arch = add(archGeometry, archMaterials[i % 2]), p = curve.getPointAt(u), t = curve.getTangentAt(u);
    arch.position.copy(p); arch.lookAt(p.x + t.x, 0, p.z + t.z);
  });
  // 地平の太陽と光の輪(霧をかけない)
  add(new THREE.CircleGeometry(13, 64), new THREE.MeshBasicMaterial({ color: css("--halo"), transparent: true, opacity: 0.35, fog: false, depthWrite: false })).position.set(0, 4.5, -121);
  add(new THREE.CircleGeometry(7, 64), new THREE.MeshBasicMaterial({ color: css("--sun"), fog: false })).position.set(0, 4.5, -120);

  const eye = new THREE.Vector3(), target = new THREE.Vector3(), state = { p: 0 };
  const draw = () => { // 進み具合 p(0〜1)の位置に立ち、少し先の点を見る
    const u = state.p * TRAVEL;
    curve.getPointAt(u, eye); curve.getPointAt(Math.min(u + LOOK_AHEAD, 1), target);
    camera.position.set(eye.x, EYE, eye.z); camera.lookAt(target.x, EYE - 0.2, target.z);
    renderer.render(scene, camera);
  };
  // スクロールに合わせて、カメラを進め、進み具合の線を伸ばし、章の列を 1 画面ずつ送る。動きを減らす設定では、カメラは止めておく
  const chapters = journey.querySelectorAll(".journey__chapter").length;
  journey.style.setProperty("--chapters", chapters);
  const tl = gsap.timeline({ defaults: { ease: "none" }, onUpdate: draw, scrollTrigger: { trigger: journey, start: "top top", end: "bottom bottom", scrub: true } })
    .to(state, { p: reduceMotion ? 0 : 1 }, 0)
    .fromTo(journey.querySelector(".journey__progress span"), { height: "0%" }, { height: "100%" }, 0)
    .to(journey.querySelector(".journey__track"), { yPercent: -100 + 100 / chapters }, 0);
  const sizes = new ResizeObserver(() => {
    camera.aspect = stage.clientWidth / stage.clientHeight; camera.updateProjectionMatrix();
    renderer.setSize(stage.clientWidth, stage.clientHeight, false); draw(); // 大きさは CSS が決めるので、style は書き換えない
  });
  sizes.observe(stage);

  journey.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    tl.scrollTrigger.kill(); tl.kill(); sizes.disconnect();
    scene.traverse((o) => o.isMesh && (o.geometry.dispose(), o.material.dispose(), o.dispose?.()));
    renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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