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