First CH Designs
BLK-69Blocks

時間帯で変わる空のバックドロップ(6シーン巡回)

未明・朝・正午・西日・日没・夜の6つの時間帯を連続で巡る、セクション背面の空。立体もモデルも画像も持たず、面1枚のフラグメントシェーダが天頂/中間/地平の3色・太陽と月・雲・星・地平の靄だけを描く。配色の移り変わりそのものが中身なので、6シーンの補間はJS側で行い、シェーダには「今の1状態」だけを渡す(同じ結果をCSSグラデーションのフォールバックにも使い回すので、非対応環境でも配色がズレない)。等速で混ぜると色相が回っているだけに見えるため、各区間の前半42%は配色を止めて溜めを作る。背景装飾でありながら下端に時刻レールを持ち、押すとその時間帯まで位相を早送りして途中の空も通る。前景は白い紙面を1枚置くので、空が真昼でも真夜中でも文字のコントラストは動かない。停止条件はprefers-reduced-motion(消さずに選んだ時間帯を1フレーム描き、自動送りだけ止める)・画面外・タブ非表示・縮小プレビューの4つ。

追加日:
2026-09-19
依存:
three
tags
#block #background #decoration #webgl #shader #gradient #sky #time-of-day #palette #motion #no-image #dynamic-import

プレビュー

18:30日没地平にいちばん濃い色が残る

朝いちばんの人にも終電あとの人にも

問い合わせが届く時間はこちらでは選べません。受付だけを24時間開けるのではなく、返信の速さと窓口の見え方まで含めて組み立てます

受付フローの設計を相談する

立体も画像も持たず、面1枚のシェーダで空だけを描いています。時間帯は自動で1周し、レールを押すとその時刻まで早送りします。

"use client";

import { useCallback, useEffect, useRef, useState } from "react";

/**
 * 時間帯で変わる空のバックドロップ(BLK-69)
 *
 * 差別化: 既存の3D/WebGL標本は「立体を組んで場所や物を見せる」ものばかりで、
 * 面に映るのは常に"かたち"だった。本ブロックはジオメトリを1枚も持たず、
 * **配色の移り変わりだけ**が中身になる全面シェーダ背景。さらに背景装飾でありながら
 * 6つの時間帯を並べた時刻レールという操作卓を持ち、待たずに全シーンを見に行ける
 * (夜景の背景装飾は操作を持たず、スクロールでカメラが進むだけだった)。
 * 前景は白い紙面を1枚置いて黒文字を載せるので、空が真昼でも真夜中でもコントラストが動かない。
 * 飽和パターンの英字キッカー・末尾の「→」リンク・角丸は使っていない。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * 1. **全面シェーダに3Dは要らない**。PlaneGeometry(2,2) と
 *    `gl_Position = vec4(position.xy, 0.0, 1.0)` で行列計算を丸ごと飛ばせる。
 *    カメラもライトも持たないので、シーングラフは板1枚だけになる。
 * 2. **シーン補間はJS側でやり、シェーダには"今の1状態"だけ渡す**。GLSLに6配色を
 *    持たせて分岐させるより安く、同じ補間結果をCSSフォールバックのグラデーションにも
 *    そのまま使い回せる(=非対応環境でも配色が1ミリもズレない)。
 * 3. **連続遷移でも「6シーンある」と読ませるには溜めを作る**。各区間の前半42%は
 *    配色を止め、後半で `smoothstep` で送る。等速で混ぜ続けると、ただの色相回転に見える。
 * 4. **飛び先へは"速度"で寄せる**(位相を巻き戻さず前へ回す)。タイムラインを直接
 *    セットすると色が飛ぶが、距離÷所要時間で位相を早送りすれば途中の空も全部通る。
 * 5. **グラデーションはディザで守る**。空のような低コントラストの広い面は8bit量子化で
 *    帯(バンディング)が出る。`hash(gl_FragCoord.xy)` を ±1/255 だけ足すと消える。
 * 6. **毎フレーム変わる表示はReactのstateにしない**。進捗バーは ref から
 *    `style.transform = scaleX()` を直接書き、stateの更新はシーン名が変わる瞬間だけにする。
 * 7. 停止条件は4つ(prefers-reduced-motion / 画面外 / タブ非表示 / 縮小プレビュー)。
 *    モーション低減時は**消さずに選んだ時間帯を1フレーム描く**(操作は生かしたまま自動送りだけ止める)。
 * 8. 非対応・コンテキストロスト時は同じ配色のCSSグラデーションへ降りる。canvasの下に
 *    常設しておき、opacityで入れ替える。
 *
 * 文言・配色・構図・シェーダはすべて First CH のオリジナル。
 */

type ThreeModule = typeof import("three");

/* ------------------------------------------------------------------ *
 * 6つの時間帯(配色の正本。シェーダもCSSフォールバックもここだけを見る)
 * ------------------------------------------------------------------ */

type Sky = {
  key: string;
  clock: string;
  label: string;
  note: string;
  /** 天頂・中間・地平の3色で空を作る */
  zenith: string;
  mid: string;
  horizon: string;
  /** 太陽/月のまわりに載る光 */
  glow: string;
  orb: string;
  cloudDark: string;
  cloudLit: string;
  /** 太陽/月の位置(0..1・yは下端が0)。0未満なら地平の下 */
  orbX: number;
  orbY: number;
  orbSize: number;
  /** 光の広がり。大きいほど締まる */
  orbSoft: number;
  stars: number;
  clouds: number;
  haze: number;
};

const SKIES: Sky[] = [
  {
    key: "predawn",
    clock: "04:20",
    label: "未明",
    note: "地平だけがうっすら温まる",
    zenith: "#0a0f24",
    mid: "#1c2750",
    horizon: "#553f5c",
    glow: "#8a5a46",
    orb: "#c98a5a",
    cloudDark: "#141b36",
    cloudLit: "#5c4560",
    orbX: 0.66,
    orbY: -0.06,
    orbSize: 0.03,
    orbSoft: 3.4,
    stars: 0.62,
    clouds: 0.38,
    haze: 0.55,
  },
  {
    key: "dawn",
    clock: "06:10",
    label: "朝",
    note: "低い光が雲の底を照らす",
    zenith: "#2c3f75",
    mid: "#8a7396",
    horizon: "#e8a869",
    glow: "#f0a94e",
    orb: "#fff1d2",
    cloudDark: "#4a4468",
    cloudLit: "#f6c98d",
    orbX: 0.72,
    orbY: 0.09,
    orbSize: 0.042,
    orbSoft: 3.0,
    stars: 0.1,
    clouds: 0.58,
    haze: 0.72,
  },
  {
    key: "noon",
    clock: "12:00",
    label: "正午",
    note: "影が短く、地平が白く飛ぶ",
    zenith: "#3d84cc",
    mid: "#9ccaee",
    horizon: "#eef4fa",
    glow: "#fff8e6",
    orb: "#ffffff",
    cloudDark: "#b9cfe3",
    cloudLit: "#ffffff",
    orbX: 0.62,
    orbY: 0.84,
    orbSize: 0.02,
    orbSoft: 7.5,
    stars: 0,
    clouds: 0.72,
    haze: 0.18,
  },
  {
    key: "golden",
    clock: "16:40",
    label: "西日",
    note: "色温度が一段下がる",
    zenith: "#4b80b9",
    mid: "#cdad88",
    horizon: "#f2c079",
    glow: "#f5b25c",
    orb: "#fff2cf",
    cloudDark: "#8f7f81",
    cloudLit: "#ffd9a2",
    orbX: 0.74,
    orbY: 0.32,
    orbSize: 0.034,
    orbSoft: 4.2,
    stars: 0,
    clouds: 0.64,
    haze: 0.5,
  },
  {
    key: "sunset",
    clock: "18:30",
    label: "日没",
    note: "地平にいちばん濃い色が残る",
    zenith: "#2f3c6e",
    mid: "#a96450",
    horizon: "#d97706",
    glow: "#e8853a",
    orb: "#ffdca8",
    cloudDark: "#4a3550",
    cloudLit: "#f2a35c",
    orbX: 0.84,
    orbY: 0.05,
    orbSize: 0.055,
    orbSoft: 2.6,
    stars: 0.06,
    clouds: 0.52,
    haze: 1,
  },
  {
    key: "night",
    clock: "22:10",
    label: "夜",
    note: "光源は月ひとつ",
    zenith: "#04060e",
    mid: "#0c1326",
    horizon: "#1d2742",
    glow: "#39497a",
    orb: "#e9eefc",
    cloudDark: "#0b1122",
    cloudLit: "#2b3860",
    orbX: 0.78,
    orbY: 0.78,
    orbSize: 0.018,
    orbSoft: 6.0,
    stars: 1,
    clouds: 0.28,
    haze: 0.22,
  },
];

const COUNT = SKIES.length;
/** 1シーンあたりの秒数。前半は溜め、後半で次へ送る(技法メモ3) */
const SEGMENT_SEC = 5.2;
const HOLD = 0.42;
/** 起点は日没。カード一覧の縮小プレビューでもアンバーが立つ */
const START = 4;

type Mixed = {
  zenith: string;
  mid: string;
  horizon: string;
  glow: string;
  orb: string;
  cloudDark: string;
  cloudLit: string;
  orbX: number;
  orbY: number;
  orbSize: number;
  orbSoft: number;
  stars: number;
  clouds: number;
  haze: number;
};

function hexToRgb(hex: string) {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const;
}

function mixHex(a: string, b: string, t: number) {
  const [ar, ag, ab] = hexToRgb(a);
  const [br, bg, bb] = hexToRgb(b);
  const to = (x: number, y: number) => Math.round(x + (y - x) * t);
  return `#${((1 << 24) | (to(ar, br) << 16) | (to(ag, bg) << 8) | to(ab, bb)).toString(16).slice(1)}`;
}

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

/** 位相(0..6の連続値)→ 今の空1状態。シェーダもCSSもこの結果だけを見る(技法メモ2) */
function skyAt(phase: number): Mixed {
  const wrapped = ((phase % COUNT) + COUNT) % COUNT;
  const i = Math.floor(wrapped);
  const frac = wrapped - i;
  const a = SKIES[i];
  const b = SKIES[(i + 1) % COUNT];
  const raw = frac <= HOLD ? 0 : (frac - HOLD) / (1 - HOLD);
  const t = raw * raw * (3 - 2 * raw);
  return {
    zenith: mixHex(a.zenith, b.zenith, t),
    mid: mixHex(a.mid, b.mid, t),
    horizon: mixHex(a.horizon, b.horizon, t),
    glow: mixHex(a.glow, b.glow, t),
    orb: mixHex(a.orb, b.orb, t),
    cloudDark: mixHex(a.cloudDark, b.cloudDark, t),
    cloudLit: mixHex(a.cloudLit, b.cloudLit, t),
    orbX: lerp(a.orbX, b.orbX, t),
    orbY: lerp(a.orbY, b.orbY, t),
    orbSize: lerp(a.orbSize, b.orbSize, t),
    orbSoft: lerp(a.orbSoft, b.orbSoft, t),
    stars: lerp(a.stars, b.stars, t),
    clouds: lerp(a.clouds, b.clouds, t),
    haze: lerp(a.haze, b.haze, t),
  };
}

/** CSSフォールバック(非対応・縮小プレビュー・読み込み前)。配色はシェーダと同一 */
function cssSky(s: Mixed) {
  const orb = s.orbY > 0 ? `radial-gradient(${s.orbSize * 190}% ${s.orbSize * 190}% at ${s.orbX * 100}% ${(1 - s.orbY) * 100}%, ${s.orb} 0%, transparent 70%),` : "";
  return {
    backgroundImage: `${orb}radial-gradient(70% 46% at ${s.orbX * 100}% ${(1 - Math.max(s.orbY, 0)) * 100}%, ${s.glow}66 0%, transparent 66%),linear-gradient(180deg, ${s.zenith} 0%, ${s.mid} 54%, ${s.horizon} 100%)`,
  };
}

/* ------------------------------------------------------------------ *
 * シェーダ
 * ------------------------------------------------------------------ */

// 行列を一切使わない全面板(技法メモ1)
const VERT = `varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);
}`;

const FRAG = `precision highp float;
varying vec2 vUv;

uniform vec2 uRes;
uniform float uTime;
uniform vec3 uZenith;
uniform vec3 uMid;
uniform vec3 uHorizon;
uniform vec3 uGlow;
uniform vec3 uOrb;
uniform vec3 uCloudDark;
uniform vec3 uCloudLit;
uniform vec2 uOrbPos;
uniform float uOrbSize;
uniform float uOrbSoft;
uniform float uStars;
uniform float uClouds;
uniform float uHaze;

float hash21(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

float fbm(vec2 p) {
  float s = 0.0;
  float a = 0.5;
  for (int i = 0; i < 5; i++) {
    s += a * vnoise(p);
    p *= 2.02;
    a *= 0.5;
  }
  return s;
}

void main() {
  vec2 p = vUv;
  float ar = max(uRes.x / max(uRes.y, 1.0), 0.001);

  // 空: 地平→中間→天頂の3色を高さで混ぜる
  vec3 col = mix(uHorizon, uMid, smoothstep(0.0, 0.54, p.y));
  col = mix(col, uZenith, smoothstep(0.40, 1.0, p.y));

  // 星: 上空だけ。グリッドの大半を捨てて点を疎にする
  if (uStars > 0.001) {
    vec2 g = vec2(p.x * ar, p.y) * 190.0;
    vec2 id = floor(g);
    float h = hash21(id);
    float on = step(0.985, h);
    float tw = 0.55 + 0.45 * sin(uTime * 1.7 + h * 220.0);
    float d = length(fract(g) - 0.5);
    float s = on * smoothstep(0.40, 0.0, d) * tw;
    col += vec3(0.86, 0.90, 1.0) * s * uStars * smoothstep(0.22, 0.72, p.y);
  }

  // 雲: 速さの違う2層を重ね、地平寄りの帯だけに出す
  float c1 = fbm(vec2(p.x * 2.9 * ar + uTime * 0.011, p.y * 4.6 + 2.0));
  float c2 = fbm(vec2(p.x * 6.2 * ar - uTime * 0.023, p.y * 8.4 - 1.0));
  float shape = c1 * 0.66 + c2 * 0.34;
  float band = smoothstep(0.03, 0.24, p.y) * (1.0 - smoothstep(0.36, 0.88, p.y));
  float mask = smoothstep(0.47, 0.78, shape) * band * uClouds;
  float lit = exp(-abs(p.x - uOrbPos.x) * 2.1);
  col = mix(col, mix(uCloudDark, uCloudLit, clamp(lit + 0.12, 0.0, 1.0)), clamp(mask, 0.0, 0.9));

  // 太陽/月のまわりの光。地平の下にあっても滲みだけは残る
  vec2 a = vec2((p.x - uOrbPos.x) * ar, p.y - uOrbPos.y);
  float d = length(a);
  col += uGlow * exp(-d * uOrbSoft) * 0.85;

  // 本体(雲より手前に置くと空に穴が開いて見えるので、雲の後に薄く載せる)
  float disc = smoothstep(uOrbSize, uOrbSize * 0.45, d) * step(0.0, uOrbPos.y);
  col = mix(col, uOrb, disc * 0.94);

  // 地平の靄
  col += uGlow * uHaze * exp(-p.y * 7.0) * 0.5;

  // 帯(バンディング)止めのディザ(技法メモ5)
  col += (hash21(gl_FragCoord.xy) - 0.5) / 255.0;

  gl_FragColor = vec4(col, 1.0);
}`;

/* ------------------------------------------------------------------ *
 * 描画(React には触れない。返した API 経由でのみ操作する)
 * ------------------------------------------------------------------ */

type CycleApi = {
  setRunning: (running: boolean) => void;
  jumpTo: (index: number) => void;
  dispose: () => void;
};

type CycleOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  progress: HTMLElement | null;
  onScene: (index: number) => void;
  onFirstFrame: () => void;
  onFail: () => void;
};

function createCycle(T: ThreeModule, opts: CycleOptions): CycleApi {
  const { canvas, host, progress, onScene, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({ canvas, antialias: false, powerPreference: "low-power" });
  // 面を塗るだけなので精細さは要らない。Retinaでも1.5倍で頭打ちにする。
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));

  const scene = new T.Scene();
  const camera = new T.Camera(); // 行列を使わないので素のCameraで足りる
  const geometry = new T.PlaneGeometry(2, 2);

  const uniforms = {
    uRes: { value: new T.Vector2(1, 1) },
    uTime: { value: 0 },
    uZenith: { value: new T.Color() },
    uMid: { value: new T.Color() },
    uHorizon: { value: new T.Color() },
    uGlow: { value: new T.Color() },
    uOrb: { value: new T.Color() },
    uCloudDark: { value: new T.Color() },
    uCloudLit: { value: new T.Color() },
    uOrbPos: { value: new T.Vector2(0.5, 0.5) },
    uOrbSize: { value: 0.03 },
    uOrbSoft: { value: 4 },
    uStars: { value: 0 },
    uClouds: { value: 0.5 },
    uHaze: { value: 0.5 },
  };

  const material = new T.ShaderMaterial({
    vertexShader: VERT,
    fragmentShader: FRAG,
    uniforms,
    depthTest: false,
    depthWrite: false,
  });
  const mesh = new T.Mesh(geometry, material);
  mesh.frustumCulled = false;
  scene.add(mesh);

  let phase = START;
  let warpTo = -1;
  let warpSpeed = 0;
  let shown = START;
  let raf = 0;
  let running = false;
  let last = 0;
  let clock = 0;
  let firstFrame = true;

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;

  const apply = () => {
    const s = skyAt(phase);
    uniforms.uZenith.value.set(s.zenith);
    uniforms.uMid.value.set(s.mid);
    uniforms.uHorizon.value.set(s.horizon);
    uniforms.uGlow.value.set(s.glow);
    uniforms.uOrb.value.set(s.orb);
    uniforms.uCloudDark.value.set(s.cloudDark);
    uniforms.uCloudLit.value.set(s.cloudLit);
    uniforms.uOrbPos.value.set(s.orbX, s.orbY);
    uniforms.uOrbSize.value = s.orbSize;
    uniforms.uOrbSoft.value = s.orbSoft;
    uniforms.uStars.value = s.stars;
    uniforms.uClouds.value = s.clouds;
    uniforms.uHaze.value = s.haze;
    uniforms.uTime.value = clock;

    // 毎フレーム変わる表示はDOMへ直接書く(技法メモ6)
    const wrapped = ((phase % COUNT) + COUNT) % COUNT;
    if (progress) progress.style.transform = `scaleX(${wrapped / COUNT})`;
    const next = Math.round(wrapped) % COUNT;
    if (next !== shown) {
      shown = next;
      onScene(next);
    }
  };

  const draw = () => {
    apply();
    renderer.render(scene, camera);
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  const tick = (now: number) => {
    raf = requestAnimationFrame(tick);
    const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);
    last = now;
    clock += dt;
    if (warpTo >= 0) {
      // 飛び先へは前へ回して寄せる(技法メモ4)
      const left = ((warpTo - phase) % COUNT + COUNT) % COUNT;
      const step = warpSpeed * dt;
      if (left <= step) {
        phase = warpTo;
        warpTo = -1;
      } else {
        phase += step;
      }
    } else {
      phase += dt / SEGMENT_SEC;
    }
    draw();
  };

  const start = () => {
    if (raf) return;
    last = 0;
    raf = requestAnimationFrame(tick);
  };
  const stop = () => {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  };

  const applyMotion = () => {
    if (!running) {
      stop();
      return;
    }
    if (motionOK) start();
    else {
      stop();
      draw(); // 消さずに1フレームだけ描く(技法メモ7)
    }
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    applyMotion();
  };
  motionQuery.addEventListener("change", onMotionChange);

  const resize = () => {
    const width = Math.max(1, host.clientWidth);
    const height = Math.max(1, host.clientHeight);
    renderer.setSize(width, height, false);
    uniforms.uRes.value.set(width, height);
  };

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  const ro = new ResizeObserver(() => {
    resize();
    if (!raf) draw();
  });
  ro.observe(host);

  resize();
  draw();

  return {
    setRunning: (next: boolean) => {
      running = next;
      applyMotion();
    },
    jumpTo: (index: number) => {
      const target = ((index % COUNT) + COUNT) % COUNT;
      if (!motionOK) {
        phase = target;
        warpTo = -1;
        draw();
        return;
      }
      const left = ((target - phase) % COUNT + COUNT) % COUNT;
      warpTo = target;
      warpSpeed = Math.max(1 / SEGMENT_SEC, left / 0.85);
      start();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ *
 * 表示
 * ------------------------------------------------------------------ */

export default function SkyTimeCycleBackdrop() {
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const progressRef = useRef<HTMLDivElement>(null);
  const apiRef = useRef<CycleApi | null>(null);
  const [scene, setScene] = useState(START);
  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、three を読み込まない。
  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: "200px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  // three 本体はここで初めてネットワークに出る(別チャンク・実寸表示の画面のみ)
  useEffect(() => {
    if (!live) 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();

      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createCycle(T, {
        canvas,
        host,
        progress: progressRef.current,
        onScene: setScene,
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });

    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  const select = useCallback((index: number) => {
    setScene(index);
    apiRef.current?.jumpTo(index);
  }, []);

  const current = SKIES[scene];
  const fallback = cssSky(skyAt(scene));

  return (
    <section className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
      <div ref={stageRef} className="relative min-h-[460px] overflow-hidden sm:min-h-[560px]">
        {/* 非対応・コンテキストロスト・縮小プレビュー・読み込み前の静止画。
            配色はシェーダと同じ関数から作る(技法メモ8) */}
        <div
          aria-hidden="true"
          className={`absolute inset-0 transition-opacity duration-700 ${
            ready && !failed ? "opacity-0" : "opacity-100"
          } motion-reduce:transition-none`}
          style={fallback}
        />

        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className={`absolute inset-0 block size-full transition-opacity duration-700 ${
            ready && !failed ? "opacity-100" : "opacity-0"
          } motion-reduce:transition-none`}
        />

        {/* 前景は白い紙面。空が真昼でも真夜中でも文字のコントラストは動かない。
            狭幅では紙面が空を覆い尽くすので、余白と字送りを詰めて上に空を残す */}
        <div className="relative flex min-h-[460px] items-end p-3.5 sm:min-h-[560px] sm:p-10">
          <div className="w-full max-w-md bg-white px-5 py-6 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.65)] sm:px-9 sm:py-9">
            <div className="flex items-baseline gap-3 border-b border-gray-200 pb-4">
              <span className="font-mono text-[13px] leading-none font-bold tracking-[0.06em] text-amber-600">
                {current.clock}
              </span>
              <span className="text-[13px] leading-none font-bold text-gray-900">
                {current.label}
              </span>
              <span className="truncate text-[11px] leading-none text-gray-400">{current.note}</span>
            </div>

            <h2 className="mt-5 text-[clamp(1.25rem,4.4vw,2.2rem)] leading-[1.42] font-bold tracking-[0.01em] text-gray-900 sm:mt-6">
              朝いちばんの人にも
              <br className="hidden sm:inline" />
              終電あとの人にも
            </h2>
            <p className="mt-4 text-[12.5px] leading-[1.95] font-medium tracking-[0.03em] text-gray-600 sm:mt-5 sm:text-[13.5px] sm:leading-[2]">
              問い合わせが届く時間はこちらでは選べません。受付だけを24時間開けるのではなく、返信の速さと窓口の見え方まで含めて組み立てます
            </p>
            <a
              href="#contact"
              className="mt-6 inline-block bg-amber-600 px-6 py-3 sm:mt-7 sm:px-7 sm:py-3.5 text-[13px] font-bold tracking-[0.06em] text-white transition-colors duration-300 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              受付フローの設計を相談する
            </a>
          </div>
        </div>
      </div>

      {/* 時刻レール: 1周のどこにいるかを示す進捗線と、6つの時間帯への飛び先 */}
      <div className="border-t border-gray-200">
        <div className="relative h-px w-full bg-gray-200">
          <div
            ref={progressRef}
            aria-hidden="true"
            className="absolute inset-y-0 left-0 w-full origin-left bg-amber-600"
            style={{ transform: `scaleX(${scene / COUNT})` }}
          />
        </div>
        <div className="grid grid-cols-6">
          {SKIES.map((s, i) => {
            const active = i === scene;
            return (
              <button
                key={s.key}
                type="button"
                aria-pressed={active}
                onClick={() => select(i)}
                className="group flex flex-col items-center gap-2 border-l border-gray-200 px-1 py-3.5 first:border-l-0 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 sm:py-4"
              >
                {/* チックの高さが変わっても列がずれないよう、器の高さは固定する */}
                <span aria-hidden="true" className="flex h-4 items-end">
                  <span
                    className={`block w-px transition-all duration-300 group-hover:bg-amber-600 motion-reduce:transition-none ${
                      active ? "h-4 bg-amber-600" : "h-2 bg-gray-300"
                    }`}
                  />
                </span>
                <span className="font-mono text-[10px] leading-none tracking-[0.04em] text-gray-400">
                  {s.clock}
                </span>
                <span
                  className={`text-[11px] leading-none font-bold transition-colors duration-300 motion-reduce:transition-none ${
                    active ? "text-gray-900" : "text-gray-500 group-hover:text-gray-900"
                  }`}
                >
                  {s.label}
                </span>
              </button>
            );
          })}
        </div>
      </div>

      <p className="border-t border-gray-200 px-5 py-3.5 font-mono text-[10px] leading-[1.7] tracking-[0.08em] text-gray-400 sm:px-8">
        立体も画像も持たず、面1枚のシェーダで空だけを描いています。時間帯は自動で1周し、レールを押すとその時刻まで早送りします。
      </p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/sky-time-cycle-backdrop.json