First CH Designs
BLK-75Blocks

断面深度ナビゲータ(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流速

01

見え方の層

最初に目に入る面。配色・余白・書体・動きの強さを決め、ブランドの印象をここで揃えます。

  • 配色設計
  • 余白設計
  • 書体選定
  • 動きの強さ

層の名称と項目は説明用のサンプルです。実際の案件では扱う範囲を合意のうえで決めます。

どの層から相談するか決める
"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