デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM+WebGL
- 種類
- シーン
- 使うもの
- three/addons/geometries/RoundedBoxGeometry.js
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
奥は Three.js の立体、手前は普通の DOM の文字です。立体(ドーナツ・球・角丸の箱・カプセル・角ばった宝石)は MeshPhysicalMaterial の金属(metalness 1)にクリアコートを重ね、灰色の部屋に細長い白い照明と暗い床を置いた自前のスタジオを PMREM でぼかして映しています。金属は周りを映すだけなので、明るい部屋だけだと灰色の樹脂に見えます。床を映すと下半分に暗い地平ができ、鋭い白い筋とあわせて金属らしく締まります。素材は銀・つや消しの鋼・黒の 3 段で、色の明るさを離して見分けやすくしています。
入りは GSAP のタイムライン 1 本です。立体は大きさを back.out で 0.12 秒ずつずらして弾ませ、カメラは遠くの右上から 3.6 秒かけて寄ります。文字はナビ → 肩のラベル → 見出しの 2 行 → 説明の行 → ボタンの順に、それぞれ overflow: hidden のマスクの下から expo.out でせり上げます。浮遊と自転、寄ったあとの小さな回り込みは描くたびに時刻から計算するので、入りが終わっても続きます。
見出しのマスクを行の箱ぴったりにすると、字の下の端が切れることがあります(欧文の g や p は別の字に読めます)。マスクに padding-bottom(字の大きさの約 0.19 倍)を足し、同じ量の負の margin-bottom で行の間隔を保ちます。出きったらマスクを外し、リンクやボタンの焦点の枠が切れないようにしています。行を自動で分けたいときは、SplitText の lines と mask でも同じせり上げになります。
背景の立体が主役なので、見出しの右だけでなく文字のまわりにも散らし、文字との間に黒の幕(左 0.6 → 右 0.3 の横のグラデーション)をかけて白い文字を読ませます。幕で暗くなる分、奥の地を明るめの灰にし、toneMappingExposure を 1.25 に上げています。狭い画面では幕を一様な濃さにし、カメラを引いて立体を収めます。
立体の形・位置・大きさ・回る周数、素材の色、カメラの寄り、せり上げる順番と時刻で調整できます。動きを減らす設定では、出そろった画面を 1 枚だけ描いて止めます。SaaS やプロダクトの LP のファーストビューに向いています。
コード
<div class="scene-hero">
<!-- 奥の立体は飾りなので、読み上げからは外す -->
<canvas class="scene-hero__canvas" aria-hidden="true"></canvas>
<!-- せり上げる部品は 1 つずつマスク(.scene-hero__mask)で包む。ナビの帯だけは上から下りてくる -->
<header class="scene-hero__header scene-hero__mask">
<div class="scene-hero__bar">
<a class="scene-hero__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="scene-hero__nav" aria-label="メイン">
<a href="/product/">製品</a>
<a href="/cases/">導入事例</a>
<a href="/pricing/">料金</a>
<a href="/docs/">ドキュメント</a>
</nav>
<!-- 開閉は、サイトのメニューにつなぐ -->
<button class="scene-hero__menu" type="button" aria-label="メニュー" aria-expanded="false"><span></span><span></span><span></span></button>
</div>
</header>
<section class="scene-hero__copy" aria-labelledby="scene-hero-title">
<span class="scene-hero__mask">
<a class="scene-hero__eyebrow" href="/news/"><span class="scene-hero__badge">NEW</span><span>ブラウザで動くリアルタイム3D<span aria-hidden="true"> →</span></span></a>
</span>
<h1 class="scene-hero__title" id="scene-hero-title">
<span class="scene-hero__mask"><span class="scene-hero__line">記憶に残る世界を、</span></span>
<span class="scene-hero__mask"><span class="scene-hero__line">ブラウザでつくる。</span></span>
</h1>
<!-- 説明は文の切れ目で自分で行に分ける(1 行ずつせり上げるため) -->
<p class="scene-hero__lead">
<span class="scene-hero__mask"><span class="scene-hero__line">ここに説明文が入ります。</span></span>
</p>
<div class="scene-hero__actions">
<span class="scene-hero__mask"><a class="scene-hero__button" href="/signup/">無料ではじめる<span aria-hidden="true"> →</span></a></span>
<span class="scene-hero__mask"><a class="scene-hero__button scene-hero__button--line" href="/demo/"><span class="scene-hero__play" aria-hidden="true"></span>デモを見る</a></span>
</div>
</section>
</div>
.scene-hero {
/* 立体の色と照明の色。script.js がこの変数を読んでシーンへ渡す */
--silver: #e2e2e2; /* 銀。金属の色は映り込みに掛かる色で、明るいほど銀に、暗いほど黒くなる */
--steel: #8c8c8c; /* つや消しの鋼 */
--black: #2a2a2a; /* 黒い金属。明るさを離して、立体を見分けやすくする */
--room: #d0d0d0; /* つやに映る部屋の色。金属の地の明るさになる */
--key: #ffffff; /* 左上からの白い光 */
--rim: #e4e4e4; /* 奥から縁を照らす光 */
--ink: #ffffff;
--soft: #eeece6;
--mute: #dedcd6;
--line: rgba(255, 255, 255, 0.2);
--on-ink: #111111; /* 白い面の上の文字 */
--gutter: clamp(24px, 7.5vw, 96px);
position: relative;
box-sizing: border-box;
min-height: 100svh;
padding: 176px var(--gutter) 96px;
overflow: hidden;
color: var(--ink);
/* 3D の部屋の奥。読み込み中と、WebGL が使えないときもこの地が見える */
background: radial-gradient(ellipse at 72% 45%, #696969 0%, #3b3b3b 42%, #151515 100%);
}
/* 重なり: 地 → 立体の後ろのにじみ(::before)→ 立体(canvas)→ 黒の幕(::after)→ 文字 */
.scene-hero::before,
.scene-hero::after { content: ""; position: absolute; }
.scene-hero::before { left: 48%; top: 4%; width: 56%; height: 92%; background: radial-gradient(closest-side, rgba(172, 172, 172, 0.3), rgba(172, 172, 172, 0)); }
.scene-hero__canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
/* 幕は文字のある左ほど濃くする */
.scene-hero::after { inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3)); }
.scene-hero__header,
.scene-hero__copy { position: relative; z-index: 1; }
.scene-hero__header { position: absolute; top: 32px; left: var(--gutter); right: var(--gutter); height: 48px; }
/* マスク。下に余白を足して、g・p の下が切れないようにする(行の間隔は負の余白で保つ) */
.scene-hero__mask { display: block; overflow: hidden; }
.scene-hero__line { display: block; }
.scene-hero__copy > .scene-hero__mask { width: fit-content; }
.scene-hero__actions .scene-hero__mask { display: inline-flex; }
.scene-hero__bar { display: flex; align-items: center; gap: 32px; height: 100%; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
.scene-hero__bar a { color: inherit; text-decoration: none; }
.scene-hero__logo { margin-right: auto; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; }
.scene-hero__nav { display: flex; gap: 32px; }
.scene-hero__menu { display: grid; gap: 6px; width: 24px; padding: 0; border: 0; background: none; cursor: pointer; }
.scene-hero__menu span { height: 2px; background: var(--ink); }
.scene-hero__copy { max-width: 720px; }
.scene-hero__eyebrow {
display: flex; align-items: center; gap: 12px; height: 32px; padding: 0 16px 0 4px; border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line); color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-decoration: none;
}
.scene-hero__badge { padding: 0 8px; border-radius: 999px; background: var(--ink); color: var(--on-ink); font-size: 11px; font-weight: 700; line-height: 24px; letter-spacing: 0.12em; }
.scene-hero__title { margin: 30.5px 0 0; font-size: clamp(32px, 8.4vw, 64px); font-weight: 800; line-height: 1.08; letter-spacing: -0.02em; }
.scene-hero__title .scene-hero__mask { padding-bottom: 0.1875em; margin-bottom: -0.1875em; }
.scene-hero__lead { margin: 24px 0 0; color: var(--soft); font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; } /* 狭い画面で、語の途中で折り返さない */
.scene-hero__actions { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 48px; }
.scene-hero__button {
display: inline-flex; align-items: center; height: 48px; padding: 0 24px; border-radius: 999px;
background: var(--ink); color: var(--on-ink); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none;
}
.scene-hero__button--line { gap: 12px; padding-left: 8px; background: none; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); }
/* 再生の印は線の丸に三角(border で描く) */
.scene-hero__play { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line); }
.scene-hero__play::before { content: ""; margin-left: 2px; border-left: 8px solid var(--ink); border-block: 5px solid transparent; }
.scene-hero a:focus-visible,
.scene-hero button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 狭い画面では、リンクをメニューにしまい、文字の下を一様に暗くする */
@media (max-width: 720px) {
.scene-hero { padding-top: 144px; }
.scene-hero__nav { display: none; }
.scene-hero::after { background: rgba(0, 0, 0, 0.55); }
}
import * as THREE from "three";
import { gsap } from "gsap";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
const DURATION = 8; // 浮遊と回り込みがひと回りするまで(秒)
const EXPOSURE = 1.25; // 黒の幕で暗くなる分、立体を明るめに写す
const CAMERA_FROM = new THREE.Vector3(1.4, 0.8, 16.5), CAMERA_TO = new THREE.Vector3(0, 0.1, 12.4); // 入りの前(遠く、少し右上)と、寄りきった位置
const LOOK = new THREE.Vector3(0.3, 0, 0), UP = new THREE.Vector3(0, 1, 0);
const ORBIT = 0.07; // 寄ったあとも回り込み続ける量(ラジアン)
const SINK = 1.2; // 文字を、マスクの高さの何倍まで沈めておくか
// せり上げる順番。[マスクの中身, 始める秒, 1 つずつずらす秒]
const STEPS = [[".scene-hero__bar", 0.5, 0], [".scene-hero__eyebrow", 0.85, 0], [".scene-hero__title .scene-hero__line", 1, 0.1], [".scene-hero__lead .scene-hero__line", 1.35, 0.07], [".scene-hero__button", 1.6, 0.08]];
const MATERIALS = { // 色は CSS の変数から読む。金属(metalness 1)にクリアコートを重ね、銀・つや消しの鋼・黒の 3 段にする
silver: { color: "--silver", roughness: 0.18, metalness: 1, clearcoat: 1, clearcoatRoughness: 0.06 },
steel: { color: "--steel", roughness: 0.3, metalness: 1, clearcoat: 1, clearcoatRoughness: 0.1 },
black: { color: "--black", roughness: 0.12, metalness: 1, clearcoat: 1, clearcoatRoughness: 0.04 },
};
const LIGHTS = [[30, 3, 6, 0, 9, 5], [3, 18, 3.2, -9, 0, 5], [2, 18, 2.4, 10, 0, -2], [40, 40, 0.12, 0, -12, 0]]; // [幅, 高さ, 明るさ, x, y, z]。上・左・右奥の細長い照明と、暗い床
const SHAPES = [ // 傾きは [x, z]、spin は 1 周で回る数(整数にするとつながる)、bob は浮遊の振れ幅。後ろの 4 つは文字のまわりに置く
{ shape: "torus", material: "silver", x: 2.55, y: 0.6, z: -0.3, size: 1.25, tilt: [1.05, 0.35], spin: 1, bob: 0.14, phase: 0 },
{ shape: "sphere", material: "black", x: 4.95, y: -1.35, z: 0.5, size: 0.9, tilt: [0, 0], spin: 0, bob: 0.12, phase: 1.3 },
{ shape: "box", material: "silver", x: 1.25, y: -1.95, z: 1.0, size: 0.95, tilt: [0.5, 0.6], spin: 1, bob: 0.1, phase: 2.4 },
{ shape: "capsule", material: "steel", x: 5.1, y: 2.05, z: -0.7, size: 0.55, tilt: [0.3, -0.9], spin: 1, bob: 0.12, phase: 3.6 },
{ shape: "gem", material: "silver", x: 0.35, y: 2.3, z: -1.4, size: 0.62, tilt: [0.4, 0.2], spin: -1, bob: 0.1, phase: 4.5 },
{ shape: "sphere", material: "steel", x: 3.35, y: -2.7, z: 1.7, size: 0.34, tilt: [0, 0], spin: 0, bob: 0.08, phase: 5.2 },
{ shape: "sphere", material: "black", x: -5.9, y: -3.5, z: -0.6, size: 1.45, tilt: [0, 0], spin: 0, bob: 0.1, phase: 0.7 },
{ shape: "capsule", material: "silver", x: -2.05, y: 2.65, z: -1.2, size: 0.5, tilt: [0.2, 1.15], spin: 1, bob: 0.1, phase: 2.0 },
{ shape: "torus", material: "steel", x: -0.95, y: -2.9, z: 1.2, size: 0.5, tilt: [1.2, -0.3], spin: -1, bob: 0.08, phase: 3.1 },
{ shape: "gem", material: "black", x: -7.4, y: 0.75, z: -2.2, size: 0.7, tilt: [0.3, 0.5], spin: 1, bob: 0.1, phase: 5.8 },
];
const GEOMETRIES = { // gem は細分 0 で面ごとの法線になり、角ばった宝石に見える
torus: () => new THREE.TorusGeometry(0.75, 0.3, 40, 96), sphere: () => new THREE.SphereGeometry(1, 48, 32), gem: () => new THREE.IcosahedronGeometry(1, 0),
box: () => new RoundedBoxGeometry(1.3, 1.3, 1.3, 4, 0.24), capsule: () => new THREE.CapsuleGeometry(0.45, 1.2, 8, 24),
};
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 映り込み用のスタジオ。灰色の部屋に細長い白い照明と暗い床を置き、PMREM でぼかす(画面には描かない)。
// 金属は周りを映すだけなので、明るい部屋だけだと灰色の樹脂に見える。床が映ると、銀の下半分に暗い地平ができて締まる
function studio(renderer, roomColor) {
const room = new THREE.Scene();
const add = (geometry, color, x, y, z) => {
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }));
mesh.position.set(x, y, z); mesh.lookAt(0, 0, 0); room.add(mesh);
};
add(new THREE.SphereGeometry(20, 32, 16), new THREE.Color(roomColor), 0, 0, 0);
for (const [w, h, power, x, y, z] of LIGHTS) add(new THREE.PlaneGeometry(w, h), new THREE.Color(power, power, power), x, y, z); // 1 を超える明るさで光らせる
const pmrem = new THREE.PMREMGenerator(renderer), target = pmrem.fromScene(room, 0.02);
room.traverse((o) => o.isMesh && (o.geometry.dispose(), o.material.dispose()));
pmrem.dispose(); return target;
}
document.querySelectorAll(".scene-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".scene-hero__canvas"), antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.NeutralToneMapping; renderer.toneMappingExposure = EXPOSURE;
const scene = new THREE.Scene(), env = studio(renderer, css("--room")); scene.environment = env.texture;
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); // 縦横比は resize で入れる
const key = new THREE.DirectionalLight(css("--key"), 1.2), rim = new THREE.DirectionalLight(css("--rim"), 2.4);
key.position.set(-4, 6, 8); rim.position.set(6, 2, -6); scene.add(key, rim);
const materials = Object.fromEntries(Object.entries(MATERIALS).map(([name, { color, ...rest }]) => [name, new THREE.MeshPhysicalMaterial({ ...rest, color: css(color) })]));
// s: 大きさ(0 → 1)と y: せり上がり、cam.p: カメラの寄り(0 → 1)。入りのタイムラインがこの値を動かし、draw が読む
const shapes = SHAPES.map((d) => ({ d, mesh: scene.add(new THREE.Mesh(GEOMETRIES[d.shape](), materials[d.material])).children.at(-1), s: 0, y: -0.7 }));
const cam = { p: 0 };
const draw = (t) => {
const a = (t / DURATION) * Math.PI * 2;
for (const { d, mesh, s, y } of shapes) {
mesh.visible = s > 0.001; mesh.scale.setScalar(d.size * Math.max(s, 0.001));
mesh.position.set(d.x, d.y + y + d.bob * Math.sin(a * 2 + d.phase), d.z);
mesh.rotation.set(d.tilt[0] + 0.25 * Math.sin(a + d.phase), d.spin * a + d.phase, d.tilt[1]);
}
// 寄りに、寄るほど効く回り込みを足す
camera.position.lerpVectors(CAMERA_FROM, CAMERA_TO, cam.p).applyAxisAngle(UP, ORBIT * Math.sin(a) * cam.p);
camera.lookAt(LOOK); renderer.render(scene, camera);
};
// 入り: 立体が弾んで現れ、カメラが寄り、文字がマスクの下から順にせり上がる(ナビの帯だけは上から)
const intro = gsap.timeline({ paused: true })
.fromTo(shapes, { s: 0, y: -0.7 }, { s: 1, y: 0, duration: 1.3, ease: "back.out(1.7)", stagger: 0.12 }, 0.25)
.fromTo(cam, { p: 0 }, { p: 1, duration: 3.6, ease: "power3.out" }, 0);
const sink = (i, el) => (el.matches(".scene-hero__bar") ? -SINK : SINK) * el.parentElement.offsetHeight;
for (const [selector, at, stagger] of STEPS) intro.from(hero.querySelectorAll(selector), { y: sink, duration: 0.9, ease: "expo.out", stagger }, at);
intro.set(hero.querySelectorAll(".scene-hero__mask"), { overflow: "visible" }); // 出きったら、焦点の枠が切れないようにマスクを外す
const sizes = new ResizeObserver(() => {
camera.aspect = hero.clientWidth / hero.clientHeight;
camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); camera.updateProjectionMatrix(); // 縦長の画面では引いて、立体を収める
renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
if (reduceMotion) draw(0);
});
const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null));
sizes.observe(hero); view.observe(hero);
if (reduceMotion) intro.progress(1); else document.fonts.ready.then(() => intro.play()); // 動きを減らす設定では、出そろった絵で止める
hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect(); intro.kill();
shapes.forEach(({ mesh }) => mesh.geometry.dispose()); Object.values(materials).forEach((m) => m.dispose());
env.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
});