断面深度ナビゲータ(8層の帯を奥へ潜る)
サイトを「見え方・言葉・並び・意味・実装・配信・計測・運用」の8層に分け、その断面をカメラで潜って見せるブロック。帯は奥行き方向に等間隔で並び、それぞれ違う速度・違う向きへ無限に流れ続ける——見かけの速度は world 速度÷距離になるので、層ごとに数値を1つ変えるだけで視差が出る。無限ループはスプライトを増やさず、文字を焼いた CanvasTexture を RepeatWrapping にして offset.x を進めるだけで作るため、帯が何本あっても板は頂点4つ・DOMノードは0のまま。操作軸は横送りではなく奥行きで、深度スライダー(キーボード可)・8つの層ボタン・canvasの縦ドラッグのいずれでもカメラが層を1枚ずつ貫通して進み、今いる層だけがアンバーで手前に来る。カメラ面を抜ける板はフェードしてから消えるので、通過の瞬間に白く走らない。抜けた板は1周ぶん奥へ回して循環させるため、どこまで潜っても前方の層が尽きない(板は8枚のまま増えない)。透過板どうしは深度バッファで解けないため depthTest を切って renderOrder を手前ほど大きく取る。canvas は装飾に徹し、層名・一文・4項目はすべてDOM側が正。テクスチャは document.fonts.ready を待ってから焼く(待たないとフォールバック書体が焼き込まれたまま固定される)。3Dライブラリは useEffect 内の動的importで別チャンクに切り出し、縮小プレビュー・画面外・タブ非表示・WebGL非対応では読み込まない。prefers-reduced-motion では起動すらせず、同じ8層を奥へ細くなるhairlineの静止断面図として見せる。英字キッカー・末尾の矢印リンク・角丸ボタンは使っていない。画像素材ゼロ。
- 追加日:
- 2026-09-23
- 依存:
- three
- tags
- #block #webgl #3d #depth #parallax #infinite-loop #band #interactive #slider #motion #hairline #no-image #dynamic-import
プレビュー
サイトを8つの層の断面で見る
01 / 08
DEPTH 01
×1.86流速
見え方の層
最初に目に入る面。配色・余白・書体・動きの強さを決め、ブランドの印象をここで揃えます。
- 配色設計
- 余白設計
- 書体選定
- 動きの強さ
層の名称と項目は説明用のサンプルです。実際の案件では扱う範囲を合意のうえで決めます。
どの層から相談するか決める"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";
const montserrat = Montserrat({ weight: ["600", "700"], subsets: ["latin"], display: "swap" });
/**
* 断面深度ナビゲータ(8層の帯を奥へ潜る)
*
* 差別化: ギャラリーの既存ブロックにも「速度の違う層を横へ無限に流す帯」はあるが、あちらは
* 操作軸が横(掴んで送る・語を選ぶ)で、層は視差を出すための無名の背景だった。本ブロックは
* 操作軸を奥行きに変え、層そのものに名前と中身を持たせている——カメラが層を1枚ずつ
* 貫通して進み、今いる深度の層だけが手前で読める大きさになる。横の流れは選べない環境音で、
* 読むのは下の台帳(深度スライダー・層ボタン・項目)が正。飽和パターンの英字キッカー・
* 末尾の「→」リンク・角丸ボタンは意図的に外している。
*
* 汎用技法メモ(web-design-playbook 還流用):
* 1. **無限に流れる帯はジオメトリを増やさない**。帯1本=頂点4つの板1枚で、文字は
* CanvasTexture に一度だけ焼き、`wrapS = RepeatWrapping` にして `texture.offset.x` を
* 毎フレーム進めるだけで途切れずに流れる(タイルの端どうしが必ず接するので継ぎ目の計算が要らない)。
* DOMマーキーの「同じ配列を2周ぶん並べて -50% まで動かす」に対応する 3D 側の定石。
* 2. **タイルの横幅は測ってから決める**。`ctx.measureText(text).width` でキャンバス幅を取ると
* 文字が引き伸ばされない。板の world 幅 W に対し `repeat.x = W / (帯の高さ × タイルの縦横比)`
* とすれば、板をどれだけ横に伸ばしても字面の縦横比が保たれる。
* 3. **フォントは焼く前に待つ**。`await document.fonts.ready` を挟まないと、テクスチャに
* フォールバック書体が焼き込まれたまま固定される(CSS と違い後から差し替わらない)。
* 書体名は `getComputedStyle(host).fontFamily` から取ると、ページ側の指定とズレない。
* 4. **遠近はカメラの前後移動で作る**。帯を z 方向に等間隔で置き、カメラの z だけを動かすと、
* 見かけの大きさ・速度・間隔が一度に正しくなる(帯ごとに scale や速度を手で調整しなくていい)。
* 横の見かけ速度は world 速度 ÷ 距離なので、world 速度を層ごとに変えるだけで視差が出る。
* 5. **カメラ面を通り抜ける板は必ずフェードで消す**。距離が 0 に近づくと板が画面いっぱいに
* 伸びて一瞬白飛びするため、`dist < 1` で非表示、そこから数ユニットかけて opacity を戻す。
* 6. 透過する板どうしは深度バッファで解決できない(`depthTest:false` にして `renderOrder` を
* 手前ほど大きく取る)。板を循環させると前後関係が入れ替わるので、順位は毎フレーム決め直す。
* 6b. **奥行きを進む演出は層を循環させないと痩せる**。カメラを抜けた板は1周ぶん奥へ回すと、
* どこまで潜っても前方に同じ密度の層が続く(板の数は8枚のまま増えない)。
* 7. 重いライブラリは静的 import しない。`useEffect` の中で `await import(...)` すれば
* 別チャンクに切り出され、実際に動かす画面でだけネットワークに出る。一覧の縮小カードでは
* `getBoundingClientRect().width ÷ offsetWidth` で scale を検出し、そもそも読み込まない。
* 8. prefers-reduced-motion では 3D を起動せず(=ライブラリも落とさず)、同じ8層を
* 奥へ向かって細くなる hairline の断面図として静止表示する。
*
* 文言・配色・構成はすべて First CH のオリジナル。
*/
type ThreeModule = typeof import("three");
type Stratum = {
no: string;
name: string;
lead: string;
items: string[];
/** world 速度(ユニット/秒)。見かけの速度は ÷距離 になるので、層ごとに揃えない */
speed: number;
dir: 1 | -1;
/** world Y。遠い層ほど中央へ収束するので、手前の層だけ大きく散らす */
y: number;
};
const STRATA: Stratum[] = [
{
no: "01",
name: "見え方の層",
lead: "最初に目に入る面。配色・余白・書体・動きの強さを決め、ブランドの印象をここで揃えます。",
items: ["配色設計", "余白設計", "書体選定", "動きの強さ"],
speed: 0.52,
dir: -1,
y: 0.62,
},
{
no: "02",
name: "言葉の層",
lead: "事業の言葉で書く層。見出し・本文・写真・図版を、読む人の判断材料として組み直します。",
items: ["見出し", "本文", "写真", "図版"],
speed: 0.34,
dir: 1,
y: -0.7,
},
{
no: "03",
name: "並びの層",
lead: "どの順で何を見せるかの層。導線・階層・語彙を1本に通し、迷う場所を残しません。",
items: ["導線整理", "階層設計", "語彙統一", "原稿構成"],
speed: 0.62,
dir: -1,
y: 0.3,
},
{
no: "04",
name: "意味の層",
lead: "機械と支援技術が読む層。見出し階層・ランドマーク・代替テキスト・入力ラベルを正しく置きます。",
items: ["見出し階層", "領域の宣言", "代替テキスト", "入力ラベル"],
speed: 0.28,
dir: 1,
y: -0.44,
},
{
no: "05",
name: "実装の層",
lead: "手で直せるコードを納める層。設計トークン・状態表現・減速配慮・印刷まで面倒を見ます。",
items: ["設計トークン", "状態表現", "減速配慮", "印刷対応"],
speed: 0.7,
dir: -1,
y: 0.78,
},
{
no: "06",
name: "配信の層",
lead: "届け方の層。画像最適化・キャッシュ・経路の冗長化・証明書の更新までを引き受けます。",
items: ["画像最適化", "キャッシュ", "経路冗長", "証明書"],
speed: 0.4,
dir: 1,
y: -0.24,
},
{
no: "07",
name: "計測の層",
lead: "測る層。見る数字を3つに絞り、表示速度と例外を監視して、次に直す1か所を毎月決めます。",
items: ["行動計測", "表示速度", "例外監視", "月次報告"],
speed: 0.58,
dir: -1,
y: 0.5,
},
{
no: "08",
name: "運用の層",
lead: "続ける層。更新手順・権限・教育資料を残し、記録ごと引き継げる状態で手を離します。",
items: ["更新手順", "権限管理", "教育資料", "引き継ぎ"],
speed: 0.32,
dir: 1,
y: -0.66,
},
];
/** 相対速度の表示用(最遅を 1.00 とする) */
const SLOWEST = Math.min(...STRATA.map((s) => s.speed));
const FOV = 38;
/** 層どうしの z 間隔(ユニット) */
const SPACING = 2.4;
/** 注目している層をカメラの何ユニット手前に置くか */
const VIEW_AHEAD = 3.4;
/** 帯の world 高さ(距離に依らず一定なので、遠い層ほど細く見える) */
const BAND_H = 0.26;
/** 8層ぶんの奥行き。カメラを抜けた板はこのぶん奥へ回して循環させる */
const CYCLE = 8 * SPACING;
const INK = 0x37332e;
const HAZE = 0xd2cec7;
const AMBER = 0xd97706;
type FieldApi = {
setDepth: (depth: number) => void;
setRunning: (running: boolean) => void;
dispose: () => void;
};
type FieldOptions = {
canvas: HTMLCanvasElement;
host: HTMLElement;
depth: number;
onFirstFrame: () => void;
onFail: () => void;
/** canvas の縦ドラッグで深度が変わったら React 側へ返す */
onDepth: (depth: number) => void;
};
/** 1層ぶんの帯テクスチャを焼く。横幅は実測した字幅に合わせるので文字が伸びない */
function makeTile(T: ThreeModule, stratum: Stratum, fontFamily: string) {
const height = 128;
const fontSize = 72;
const probe = document.createElement("canvas");
const probeCtx = probe.getContext("2d");
const font = `700 ${fontSize}px ${fontFamily}`;
const text = `${stratum.name} ${stratum.items.join(" ")} `;
let width = 1600;
if (probeCtx) {
probeCtx.font = font;
width = Math.max(320, Math.ceil(probeCtx.measureText(text).width));
}
const tile = document.createElement("canvas");
tile.width = width;
tile.height = height;
const ctx = tile.getContext("2d");
if (ctx) {
// 白で焼いて material.color で染める(層ごとにテクスチャを作り直さずに色を変えられる)
ctx.font = font;
ctx.fillStyle = "#ffffff";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, 0, height / 2 + 2);
}
const texture = new T.CanvasTexture(tile);
texture.wrapS = T.RepeatWrapping;
texture.wrapT = T.ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.minFilter = T.LinearFilter;
texture.magFilter = T.LinearFilter;
texture.colorSpace = T.SRGBColorSpace;
return { texture, aspect: width / height };
}
/**
* 8層の帯を z 方向に並べ、カメラだけを前後に動かす。React には触れず、返した API 経由で操作する
* (毎フレームの再レンダーを避けるため、状態はすべてクロージャに閉じ込める)。
*/
function createStrataField(T: ThreeModule, opts: FieldOptions): FieldApi {
const { canvas, host, onFirstFrame, onFail, onDepth } = opts;
const renderer = new T.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "low-power",
});
let dpr = Math.min(window.devicePixelRatio || 1, 1.75);
renderer.setPixelRatio(dpr);
const scene = new T.Scene();
const camera = new T.PerspectiveCamera(FOV, 1, 0.1, 80);
const fontFamily = getComputedStyle(host).fontFamily || "sans-serif";
const geometry = new T.PlaneGeometry(1, 1);
const inkColor = new T.Color(INK);
const hazeColor = new T.Color(HAZE);
const amberColor = new T.Color(AMBER);
const work = new T.Color();
type Band = {
mesh: THREE.Mesh;
material: THREE.MeshBasicMaterial;
texture: THREE.Texture;
aspect: number;
stratum: Stratum;
index: number;
/** 流れた world 距離 */
flow: number;
};
const bands: Band[] = STRATA.map((stratum, i) => {
const { texture, aspect } = makeTile(T, stratum, fontFamily);
const material = new T.MeshBasicMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
toneMapped: false,
});
const mesh = new T.Mesh(geometry, material);
mesh.position.set(0, stratum.y, -SPACING * i);
scene.add(mesh);
return { mesh, material, texture, aspect, stratum, index: i, flow: i * 1.7 };
});
let depth = opts.depth;
let depthTarget = opts.depth;
let camX = 0;
let camY = 0;
let camXTarget = 0;
let camYTarget = 0;
/** ドラッグで手動に送った world 量(次フレームで各層へ配る) */
let scrub = 0;
let raf = 0;
let last = 0;
let firstFrame = true;
let frames = 0;
let elapsed = 0;
let degraded = false;
let aspect = 16 / 9;
const tanHalf = Math.tan((FOV * Math.PI) / 360);
const viewHeightAt = (dist: number) => 2 * tanHalf * dist;
const clampDepth = (v: number) => Math.min(STRATA.length - 1, Math.max(0, v));
const resize = () => {
const width = Math.max(1, host.clientWidth);
const height = Math.max(1, host.clientHeight);
aspect = width / height;
camera.aspect = aspect;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const update = (dt: number) => {
// 深度・視差はすべて指数補間で寄せる(停止と再開が段差なくつながる)
const ease = 1 - Math.pow(0.0009, dt);
depth += (depthTarget - depth) * ease;
camX += (camXTarget - camX) * ease;
camY += (camYTarget - camY) * ease;
camera.position.set(camX, camY, -SPACING * depth + VIEW_AHEAD);
for (const band of bands) {
// 層は循環させる: カメラを抜けた板は1周ぶん奥へ回す(運用の層の先はまた見え方の層=
// 改善が一周する)。こうしないと深く潜るほど前方が空になり、舞台が痩せていく。
let z = -SPACING * band.index;
let dist = camera.position.z - z;
while (dist < 1.2) {
z -= CYCLE;
dist += CYCLE;
}
while (dist - CYCLE > 1.2) {
z += CYCLE;
dist -= CYCLE;
}
band.mesh.position.z = z;
// 並び順は毎フレーム決め直す(回した板の前後関係が入れ替わるため・手前ほど後に描く)
band.mesh.renderOrder = Math.round(1000 - dist * 10);
// 流れ: 自走ぶん+ドラッグぶん(ドラッグは手前ほど大きく動かして視差を保つ)
band.flow += band.stratum.dir * band.stratum.speed * dt + scrub * (VIEW_AHEAD / dist);
const width = viewHeightAt(dist) * aspect * 1.25;
band.mesh.scale.set(width, BAND_H, 1);
const tileWorld = BAND_H * band.aspect;
band.texture.repeat.x = width / tileWorld;
band.texture.offset.x = band.flow / tileWorld;
// カメラ面を抜ける手前でフェード(伸び切った板が白く走るのを防ぐ)
const enter = Math.min(1, (dist - 1) / 1.6);
const recede = Math.max(0, 1 - (dist - VIEW_AHEAD) / (SPACING * 4.5));
const focus = Math.max(0, 1 - Math.abs(depth - band.index));
// 縦位置は距離の平方根で広げる(そのままだと遠い層が消失点へ潰れて画面中央に団子になる)。
// 注目した層だけ中央へ寄せるので、読む位置は毎回同じ高さに来る。
band.mesh.position.y = band.stratum.y * (1 - 0.62 * focus) * Math.sqrt(dist / VIEW_AHEAD);
work.copy(hazeColor).lerp(inkColor, recede).lerp(amberColor, focus * 0.92);
band.material.color.copy(work);
band.material.opacity = enter * (0.05 + 0.95 * Math.pow(recede, 2.2));
}
scrub = 0;
};
const renderOnce = () => {
renderer.render(scene, camera);
if (firstFrame) {
firstFrame = false;
onFirstFrame();
}
};
const frame = (now: number) => {
raf = requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.06);
last = now;
update(dt);
renderOnce();
// 低スペック対策: 立ち上がり10フレームを捨て、続く48フレームの平均で二段階に落とす
frames += 1;
if (frames > 10 && frames <= 58) {
elapsed += dt * 1000;
if (frames === 58 && elapsed / 48 > 32) {
if (degraded) {
onFail();
stop();
} else {
degraded = true;
dpr = Math.max(1, dpr * 0.6);
renderer.setPixelRatio(dpr);
resize();
frames = 0;
elapsed = 0;
}
}
}
};
const start = () => {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!raf) return;
cancelAnimationFrame(raf);
raf = 0;
};
// --- ポインタ操作: 横=流れを掴んで送る / 縦=深度を潜る(タッチは縦をページへ渡す)-----
let pointerId: number | null = null;
let lastX = 0;
let lastY = 0;
let coarse = false;
const onPointerDown = (e: PointerEvent) => {
if (pointerId !== null || e.button !== 0) return;
pointerId = e.pointerId;
coarse = e.pointerType === "touch";
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
const width = Math.max(1, host.clientWidth);
if (pointerId === null) {
// 掴んでいない間は、位置に応じてカメラをわずかに振る(層ごとの視差が出る)
const rect = host.getBoundingClientRect();
camXTarget = ((e.clientX - rect.left) / rect.width - 0.5) * -0.7;
camYTarget = ((e.clientY - rect.top) / rect.height - 0.5) * 0.34;
return;
}
if (e.pointerId !== pointerId) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
scrub += (-dx / width) * viewHeightAt(VIEW_AHEAD) * aspect;
if (!coarse) {
// 上へ引くほど深く潜る
depthTarget = clampDepth(depthTarget - dy * 0.006);
}
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
pointerId = null;
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
// 離したら最寄りの層へ寄せ、台帳側の選択と一致させる
const snapped = Math.round(clampDepth(depthTarget));
depthTarget = snapped;
onDepth(snapped);
};
const onPointerLeave = () => {
if (pointerId !== null) return;
camXTarget = 0;
camYTarget = 0;
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerUp);
canvas.addEventListener("pointerleave", onPointerLeave);
const onContextLost = (e: Event) => {
e.preventDefault();
stop();
onFail();
};
canvas.addEventListener("webglcontextlost", onContextLost);
const ro = new ResizeObserver(() => {
resize();
if (!raf) {
update(0);
renderOnce();
}
});
ro.observe(host);
resize();
update(0);
renderOnce();
return {
setDepth: (next: number) => {
depthTarget = clampDepth(next);
if (!raf) {
update(0.016);
renderOnce();
}
},
setRunning: (next: boolean) => {
if (next) start();
else stop();
},
dispose: () => {
stop();
ro.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerUp);
canvas.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("webglcontextlost", onContextLost);
for (const band of bands) {
band.texture.dispose();
band.material.dispose();
}
geometry.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}
export default function DepthStrataNavigator() {
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const apiRef = useRef<FieldApi | null>(null);
const [depth, setDepth] = useState(0);
const [live, setLive] = useState(false);
const [inView, setInView] = useState(false);
const [pageVisible, setPageVisible] = useState(true);
const [motionOK, setMotionOK] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// モーション低減時はそもそも 3D を起動しない(ライブラリも取りに行かない)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => setMotionOK(!query.matches);
apply();
query.addEventListener("change", apply);
return () => query.removeEventListener("change", apply);
}, []);
// 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
// rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dを読み込まない。
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
const io = new IntersectionObserver(
([entry]) => {
setInView(entry.isIntersecting);
if (entry.isIntersecting && fullSize) setLive(true);
},
{ rootMargin: "160px" },
);
io.observe(el);
const onVisibility = () => setPageVisible(document.visibilityState === "visible");
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
useEffect(() => {
if (!live || !motionOK) return;
const canvas = canvasRef.current;
const host = stageRef.current;
if (!canvas || !host) return;
let cancelled = false;
const boot = async () => {
// 先に対応可否を確かめる(非対応なら重いライブラリを取りに行く意味がない)
const probe = document.createElement("canvas");
const gl =
probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
if (!gl) {
setFailed(true);
return;
}
gl.getExtension("WEBGL_lose_context")?.loseContext();
// 焼く前に書体を待つ。待たないとフォールバック書体がテクスチャに固定される
if (document.fonts?.ready) await document.fonts.ready;
const T = await import("three");
if (cancelled) return;
apiRef.current = createStrataField(T, {
canvas,
host,
depth: 0,
onFirstFrame: () => setReady(true),
onFail: () => setFailed(true),
onDepth: (next) => setDepth(next),
});
};
boot().catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
apiRef.current?.dispose();
apiRef.current = null;
setReady(false);
};
}, [live, motionOK]);
useEffect(() => {
apiRef.current?.setRunning(inView && pageVisible && !failed);
}, [inView, pageVisible, failed, ready]);
useEffect(() => {
apiRef.current?.setDepth(depth);
}, [depth, ready]);
const current = STRATA[depth];
const canvasLive = ready && !failed && motionOK;
return (
<section
aria-labelledby="depth-strata-title"
className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
>
{/* 見出し行: 英字キッカーは置かず、和文見出しと計器だけで始める */}
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 px-5 py-4 sm:px-8 sm:py-5">
<h2
id="depth-strata-title"
className="flex items-center gap-2 text-[14px] font-bold tracking-[0.04em] text-gray-900 sm:text-[15px]"
>
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
サイトを8つの層の断面で見る
</h2>
<p className={`${montserrat.className} text-[11px] font-semibold tracking-[0.1em] text-gray-400`}>
<span className="text-amber-600">{current.no}</span> / {STRATA.length
.toString()
.padStart(2, "0")}
</p>
</div>
{/* 断面の舞台。3Dが立ち上がるまで/非対応/モーション低減時は下の静止断面図がそのまま残る */}
<div
ref={stageRef}
className="relative h-[244px] overflow-hidden border-y border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#fbfaf8_52%,#f3f0ea_100%)] select-none sm:h-[320px]"
>
{/* 静止断面図(装飾): 奥へ行くほど細く・薄く・右へ寄る。OG画像と一覧カードもこの絵になる */}
<div
aria-hidden="true"
className={`absolute inset-0 flex flex-col justify-center gap-[2.4%] px-5 transition-opacity duration-700 sm:px-8 ${
canvasLive ? "opacity-0" : "opacity-100"
} motion-reduce:transition-none`}
>
{STRATA.map((stratum, i) => {
const on = i === depth;
return (
<div
key={stratum.no}
className="flex items-center gap-2 sm:gap-3"
style={{
marginLeft: `${i * 3.4}%`,
marginRight: `${i * 1.6}%`,
opacity: on ? 1 : 1 - i * 0.085,
}}
>
<span
className={`${montserrat.className} text-[10px] font-semibold tracking-[0.1em] ${
on ? "text-amber-600" : "text-gray-400"
}`}
>
{stratum.no}
</span>
<span
className={`text-[11px] font-bold whitespace-nowrap sm:text-[12px] ${
on ? "text-amber-700" : "text-gray-700"
}`}
style={{ fontSize: `${12 - i * 0.35}px` }}
>
{stratum.name}
</span>
<span className={`h-px flex-1 ${on ? "bg-amber-600" : "bg-gray-300"}`} />
<span className="hidden text-[10px] whitespace-nowrap text-gray-400 sm:inline">
{stratum.items[0]}
</span>
</div>
);
})}
</div>
<canvas
ref={canvasRef}
aria-hidden="true"
// 両端は mask で断ち切る(帯が枠線にぶつかって切れるのを見せない)
className={`absolute inset-0 block size-full touch-pan-y [mask-image:linear-gradient(to_right,transparent_0,#000_6%,#000_94%,transparent_100%)] transition-opacity duration-700 ${
canvasLive ? "cursor-grab opacity-100 active:cursor-grabbing" : "pointer-events-none opacity-0"
} motion-reduce:hidden motion-reduce:transition-none`}
/>
<p
className={`${montserrat.className} pointer-events-none absolute top-3 right-4 text-[10px] tracking-[0.12em] text-gray-400 transition-opacity duration-500 sm:top-3.5 ${
canvasLive ? "opacity-100" : "opacity-0"
} motion-reduce:transition-none`}
>
DEPTH {(depth + 1).toString().padStart(2, "0")}
</p>
</div>
{/* 深度スライダー: 操作軸は横送りではなく奥行き。キーボードでも1層ずつ潜れる */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 px-5 py-4 sm:px-8">
<label
htmlFor="depth-strata-range"
className="text-[11px] font-bold tracking-[0.08em] whitespace-nowrap text-gray-500"
>
深度
</label>
<input
id="depth-strata-range"
type="range"
min={0}
max={STRATA.length - 1}
step={1}
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
aria-valuetext={`${current.no} ${current.name}`}
className="h-1 min-w-[160px] flex-1 cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
/>
<p className={`${montserrat.className} text-[11px] font-semibold tracking-[0.08em] whitespace-nowrap text-gray-400`}>
×{(current.speed / SLOWEST).toFixed(2)}
<span className="ml-1 font-sans text-[10px] font-normal tracking-normal text-gray-400">
流速
</span>
</p>
</div>
{/* 読むのはこちらが正(canvas は装飾)。層の名前・一文・4項目を同じ段に置く */}
<div className="border-t border-gray-200 px-5 py-5 sm:px-8 sm:py-6">
<div className="flex items-baseline gap-3">
<span
className={`${montserrat.className} text-[12px] font-bold tracking-[0.1em] text-amber-600`}
>
{current.no}
</span>
<h3 className="text-[15px] font-bold tracking-[0.03em] text-gray-900 sm:text-[16px]">
{current.name}
</h3>
</div>
<p className="mt-2.5 max-w-xl text-[13px] leading-[1.95] text-gray-600">{current.lead}</p>
<ul className="mt-3.5 flex flex-wrap gap-x-4 gap-y-1.5">
{current.items.map((item) => (
<li
key={item}
className="flex items-center gap-2 text-[11.5px] font-medium tracking-[0.04em] text-gray-500"
>
<span aria-hidden="true" className="inline-block h-px w-3 shrink-0 bg-amber-600" />
{item}
</li>
))}
</ul>
</div>
{/* 層の台帳=そのまま深度スイッチ。hairline は gap-px と下地の bg-gray-200 で作る */}
<div className="grid grid-cols-2 gap-px border-t border-gray-200 bg-gray-200 sm:grid-cols-4">
{STRATA.map((stratum, i) => {
const on = i === depth;
return (
<button
key={stratum.no}
type="button"
aria-pressed={on}
onClick={() => setDepth(i)}
className="group flex items-center gap-2 bg-white px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 sm:px-4"
>
<span
aria-hidden="true"
className={`h-5 w-px shrink-0 origin-center transition-transform duration-300 ease-out motion-reduce:transition-none ${
on ? "scale-y-100 bg-amber-600" : "scale-y-0 bg-amber-600 group-hover:scale-y-100"
}`}
/>
<span className="min-w-0">
<span
className={`${montserrat.className} block text-[10px] font-semibold tracking-[0.1em] ${
on ? "text-amber-600" : "text-gray-400"
}`}
>
{stratum.no}
</span>
<span
className={`mt-0.5 block truncate text-[12px] font-bold tracking-[0.03em] transition-colors ${
on ? "text-gray-900" : "text-gray-500 group-hover:text-gray-900"
}`}
>
{stratum.name}
</span>
</span>
</button>
);
})}
</div>
{/* 注記とCTA: 角丸なし・矢印なしの四角いリンクにする */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4 border-t border-gray-200 px-5 py-5 sm:px-8">
<p className="max-w-sm text-[11px] leading-[1.9] text-gray-500">
層の名称と項目は説明用のサンプルです。実際の案件では扱う範囲を合意のうえで決めます。
</p>
<a
href="#contact"
className="inline-flex items-center gap-2.5 border border-gray-900 px-4 py-2.5 text-[12px] font-bold tracking-[0.06em] text-gray-900 transition-colors duration-300 hover:bg-gray-900 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
どの層から相談するか決める
</a>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/depth-strata-navigator.json