First CH Designs
BLK-69Blocks

Time-of-Day Sky Backdrop (Six-Scene Cycle)

A section backdrop that cycles continuously through six times of day — pre-dawn, morning, noon, late afternoon, sunset and night. No meshes, models or images: a single full-screen fragment shader paints a three-stop sky, a sun or moon, drifting cloud bands, stars and horizon haze. Palette interpolation happens in JS and only the current state reaches the shader, so the same colours drive the CSS gradient fallback. Each segment holds for the first 42% before easing to the next, so the six scenes stay readable instead of blurring into a hue rotation. A hairline time rail lets you fast-forward the phase to any scene, and the white foreground sheet keeps text contrast constant from noon to midnight. Stops on prefers-reduced-motion (still frame kept), off-screen, hidden tab and scaled-down previews.

Added:
2026-09-19
Dependencies:
three
tags
#block #background #decoration #webgl #shader #gradient #sky #time-of-day #palette #motion #no-image #dynamic-import

Preview

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>
  );
}

Add to your project via shadcn CLI

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