Depth Strata Navigator (Eight Drifting Bands)
A block that slices a website into eight strata — surface, wording, ordering, semantics, build, delivery, measurement and operation — and lets you fly the camera through the cross-section. The bands sit at even z intervals and drift horizontally forever, each at its own speed and direction; because apparent speed is world speed divided by distance, one number per layer is enough to produce parallax. The infinite loop needs no extra sprites: the text is baked once into a CanvasTexture set to RepeatWrapping and only offset.x advances, so every band stays a four-vertex quad with zero DOM nodes. The axis of interaction is depth rather than horizontal scroll — a keyboard-operable depth slider, eight layer buttons, or a vertical drag on the canvas moves the camera through one plane at a time, and only the current layer comes forward in amber. Planes crossing the camera fade out before they are culled, so the pass-through never flares, and each one is recycled a full cycle deeper — the field never thins out however far you dive, and the count stays at eight quads. Transparent quads can't be resolved by the depth buffer, so depthTest is off and renderOrder rises toward the viewer. The canvas is decorative; layer names, the one-line summary and the four items live in the DOM. Textures are baked after document.fonts.ready, the 3D library is dynamically imported into its own chunk, and nothing loads in scaled-down previews, off-screen, on hidden tabs or without WebGL. Under prefers-reduced-motion the 3D never starts and the same eight strata are shown as a static hairline cross-section. No image assets.
- Added:
- 2026-09-23
- Dependencies:
- three
- tags
- #block #webgl #3d #depth #parallax #infinite-loop #band #interactive #slider #motion #hairline #no-image #dynamic-import
Preview
サイトを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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/depth-strata-navigator.json