流れる雲
背景エフェクト / 動く素材トップ
等高線でできた雲がゆっくり流れ、触れた跡がしばらく残る。空・地域・案内。明るい地に静かな動きを置きたいとき。
試して調整する#
判断#
向く場面: 空・地域・案内。明るい地に静かな動きを置きたいとき。
避ける場面: 文字が主役の面に、そのまま重ねること。
つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。
組み込み条件#
依存: 素のCanvas 2D。外部ライブラリは要らない。
負荷: 1画面に1つ。画面外・非表示タブ・動きを減らす設定で止まる。
再利用: コピーした単一HTMLがそのまま動く。
単一HTMLをコピーする#
下のコードだけで実物サンプルと同じ画面が動きます。調整欄を動かすと、値を反映したコードへ更新されます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,follow">
<title>流れる雲 実物サンプル</title>
<style>
:root {
color-scheme: light dark;
--ink-light:#1a2421; --mut-light:#5a6a72; --signal-light:#2a7ab8; --bg-light:#f4f8fb;
--ink-dark:#f4f8fb; --mut-dark:#a8bac7; --signal-dark:#48b8ff; --bg-dark:#07111f;
}
* { box-sizing:border-box; }
html,body { margin:0; min-height:100%; }
body { font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif; transition:background .3s,color .3s; }
html[data-demo-theme="light"], html[data-demo-theme="light"] body {
background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); }
html[data-demo-theme="dark"], html[data-demo-theme="dark"] body {
background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); }
@media (prefers-color-scheme:light) {
html:not([data-demo-theme]), html:not([data-demo-theme]) body {
background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); } }
@media (prefers-color-scheme:dark) {
html:not([data-demo-theme]), html:not([data-demo-theme]) body {
background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); } }
.sample { position:relative; min-height:100dvh; overflow:hidden; isolation:isolate; display:grid; align-items:end; }
.effect { position:absolute; inset:0; z-index:0; }
.effect canvas { display:block; width:100%; height:100%; touch-action:manipulation; }
.copy { position:relative; z-index:1; width:min(42rem,100%); padding:clamp(2rem,7vw,6rem); }
.label { margin:0 0 1rem; color:var(--signal); font:600 .72rem/1.4 ui-monospace,Consolas,monospace; letter-spacing:.16em; }
h1 { margin:0; max-width:14ch; font-size:clamp(2rem,5.5vw,4.5rem); line-height:1.05; letter-spacing:-.04em; }
.lead { margin:1.5rem 0 0; max-width:36rem; color:var(--mut); font-size:clamp(.95rem,1.4vw,1.1rem); line-height:1.85; }
.readout { position:absolute; inset:1.5rem 1.5rem auto auto; margin:0; color:var(--mut); font:500 .68rem/1.6 ui-monospace,Consolas,monospace; letter-spacing:.1em; writing-mode:vertical-rl; z-index:2; }
.theme-toggle { position:absolute; inset:1.5rem auto auto 1.5rem; z-index:2; display:flex; gap:.25rem; }
.theme-toggle button {
min-block-size:36px; padding-inline:.75rem; font:600 .72rem/1 ui-monospace,Consolas,monospace;
letter-spacing:.08em; border:1px solid var(--mut); background:transparent; color:var(--ink); cursor:pointer; border-radius:4px; }
.theme-toggle button[aria-pressed="true"] { background:var(--signal); color:var(--bg-dark); border-color:var(--signal); }
@media (max-width:640px) { .copy{padding:2rem 1.25rem 3rem}.readout{display:none} }
.bare .copy, .bare .readout, .bare .theme-toggle { display:none; }
</style>
</head>
<body>
<script id="effect-config" type="application/json">{"DRIFT_SPEED":0.3,"IMPRINT_DECAY":0.997,"IMPRINT_GAIN":0.06,"POINTER_RADIUS":400,"CELL":20,"FIELD_MIN":-2,"FIELD_MAX":3.6,"FILL_ALPHA_MIN":0.005,"FILL_ALPHA_MAX":0.84,"FILL_STEP":2,"TIME_SCALE":0.4,"POINTER_EASE":0.1,"AMP_EASE":0.06,"IMPRINT_MAX":2.8,"RIDGE":0.05,"OCTAVES":3.3,"LACUNARITY":2.0,"GAIN":0.4,"WARP":4.0,"FIELD_AMPLITUDE":3.0,"GRADIENT_AMP":-6.8}</script>
<script>if (/[?&]bare=1/.test(location.search)) document.documentElement.classList.add('bare');
if (/[?&]tap=1/.test(location.search)) { addEventListener('load', function () {
setTimeout(function () { [[0.5,0.5],[0.3,0.6],[0.7,0.4]].forEach(function (p, i) {
setTimeout(function () { document.dispatchEvent(new MouseEvent('click',
{clientX: innerWidth * p[0], clientY: innerHeight * p[1], bubbles: true})); }, i * 220);
}); }, 300); }); }</script>
<script>(function(){var m=location.hash.match(/[#&]cfg=([^&]*)/);if(!m)return;
try{JSON.parse(decodeURIComponent(m[1]));document.getElementById('effect-config').textContent=decodeURIComponent(m[1]);}catch(e){}})();</script>
<script>(function(){
var root=document.documentElement, cfgNode=document.getElementById('effect-config');
var themeKeys=[];
function syncTheme(t){
root.setAttribute('data-demo-theme',t);
document.querySelectorAll('.theme-toggle button').forEach(function(b){
b.setAttribute('aria-pressed',b.dataset.theme===t?'true':'false');});
if(!themeKeys.length)return;
try{var cfg=JSON.parse(cfgNode.textContent||'{}');
themeKeys.forEach(function(k){cfg[k]=t==='dark'?0:1;});
cfgNode.textContent=JSON.stringify(cfg);}catch(e){}
}
var fixed=null;
if(fixed){syncTheme(fixed);}else{var q=location.search.match(/[?&]theme=(light|dark)/); if(q) syncTheme(q[1]);}
addEventListener('DOMContentLoaded',function(){
document.querySelectorAll('.theme-toggle button').forEach(function(b){
b.addEventListener('click',function(){syncTheme(b.dataset.theme);});});});})();</script>
<main class="sample">
<div class="theme-toggle" aria-label="配色切替">
<button type="button" data-theme="light" aria-pressed="false">LIGHT</button>
<button type="button" data-theme="dark" aria-pressed="false">DARK</button>
</div>
<div class="effect" aria-hidden="true"><div class="site-home-hero"><canvas class="site-home-field-canvas" id="effect-canvas"></canvas></div></div>
<div class="copy">
<p class="label">背景エフェクト</p>
<h1>流れる雲</h1>
<p class="lead">等高線でできた雲がゆっくり流れ、触れた跡がしばらく残る。空・地域・案内。明るい地に静かな動きを置きたいとき。空・地域・案内。明るい地に静かな動きを置きたいとき。</p>
</div>
<p class="readout">単一HTML・動きを減らす設定に対応</p>
</main>
<script>
/* ---- ここから下は元の実装をそのまま持ってきたもの --------------------- */
var __CFG__ = JSON.parse(document.getElementById('effect-config').textContent || '{}');
(() => {
function hexToRgb(hex) {
const value = hex.replace("#", "");
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16),
];
}
const SHOW_CONTOURS = false; // 等高線を表示する/しない
const FIELD_HEX = "#0288d1"; // 塗りの色。空・海・プールのようなクリアな青
const CONTOUR_HEX = FIELD_HEX; // 等高線の色(塗りと同色・透明度で溶かす)
const FIELD_RGB = hexToRgb(FIELD_HEX);
const CONTOUR_RGB = hexToRgb(CONTOUR_HEX);
const STROKE_RGB = CONTOUR_RGB.join(", "); // strokeStyle 用の「R, G, B」文字列
const [FILL_R, FILL_G, FILL_B] = FIELD_RGB; // ImageData 用に R/G/B を展開
const STROKE_ALPHA = SHOW_CONTOURS ? 0.2 : 0; // 大きくすると:等高線が 濃く 見える
const FIELD_MIN = (__CFG__["FIELD_MIN"] !== undefined ? __CFG__["FIELD_MIN"] : (-2)); // 大きくすると:低い値側のコントラストが変わる(やや複雑) 雲が多くなる、雑になる -1
const FIELD_MAX = (__CFG__["FIELD_MAX"] !== undefined ? __CFG__["FIELD_MAX"] : (3.6)); // 大きくすると:高い値側のコントラストが変わる(やや複雑) 雲と空の区別がなくなる 2.6~3.6
const FILL_ALPHA_MIN = (__CFG__["FILL_ALPHA_MIN"] !== undefined ? __CFG__["FILL_ALPHA_MIN"] : (0.005)); // 大きくすると:薄い部分が 濃く なる(濃淡がなくなる)
const FILL_ALPHA_MAX = (__CFG__["FILL_ALPHA_MAX"] !== undefined ? __CFG__["FILL_ALPHA_MAX"] : (0.84)); // 大きくすると:濃い部分が より濃く(全体が目立つ)なる(絵画っぽくなる)↔減らすと曇る
const FILL_STEP = (__CFG__["FILL_STEP"] !== undefined ? __CFG__["FILL_STEP"] : (2)); // 大きくすると:描画が粗くなり 軽く なる(画質はやや落ちる)
const CELL = (__CFG__["CELL"] !== undefined ? __CFG__["CELL"] : (20)); // 大きくすると:うねりの 塊が大きく なる(粗い) 20:20~30
const TIME_SCALE = (__CFG__["TIME_SCALE"] !== undefined ? __CFG__["TIME_SCALE"] : (0.4)); // 大きくすると:速く動く(時間が速く経過する) 0.4~0.8
const POINTER_EASE = (__CFG__["POINTER_EASE"] !== undefined ? __CFG__["POINTER_EASE"] : (0.1)); // 大きくすると:マウス位置に 素早く ついてくる(キビキビ)
const AMP_EASE = (__CFG__["AMP_EASE"] !== undefined ? __CFG__["AMP_EASE"] : (0.06)); // 大きくすると:カーソルが入ったとき 描き込みが 早く 立ち上がる
const POINTER_RADIUS = (__CFG__["POINTER_RADIUS"] !== undefined ? __CFG__["POINTER_RADIUS"] : (400)); // 大きくすると:マウスの影響が広くなる(周辺まで動く)
// ─── マウスの痕跡:離れても残す蓄積バッファ(足し算ではなく描き込んで記憶する) ───
const IMPRINT_GAIN = (__CFG__["IMPRINT_GAIN"] !== undefined ? __CFG__["IMPRINT_GAIN"] : (0.06)); // 1フレームの描き込み量。大きいほど 跡が早く濃く 付く
const IMPRINT_DECAY = (__CFG__["IMPRINT_DECAY"] !== undefined ? __CFG__["IMPRINT_DECAY"] : (0.997)); // 跡の自己回復。1=完全に残す(永続)/ 小さいほど 早く癒える
const IMPRINT_MAX = (__CFG__["IMPRINT_MAX"] !== undefined ? __CFG__["IMPRINT_MAX"] : (2.8)); // 跡の濃さの上限。飽和・白飛びを防ぐクランプ(旧 POINTER_AMP 相当の強さ)
const LEVELS = [-2.4, -1.8, -1.2, -0.6, 0, 0.6, 1.2, 1.8, 2.4]; // 等高線の本数・間隔
// const LEVELS = [-2.2, -1.8, -1.44, -1.12, -0.84, -0.6, -0.4, -0.24, -0.12, -0.04, 0, 0.04, 0.12, 0.24, 0.4, 0.6, 0.84, 1.12, 1.44, 1.8, 2.2]; // 等高線の本数・間隔
// const LEVELS = [-2.2, -1.44, -0.84, -0.4, -0.12, 0, 0.12, 0.4, 0.84, 1.44, 2.2]; // 等高線の本数・間隔
const FIELD_RANGE = FIELD_MAX - FIELD_MIN;
// ─── 地形シェイプ:円形のうねり ⇄ 山脈の尾根と谷 ───
const RIDGE = (__CFG__["RIDGE"] !== undefined ? __CFG__["RIDGE"] : (0.05)); // 0: なめらかな円形のうねり / 1: 山脈の尾根と谷。間で度合いを調整 見た目が複雑化 0.05~0.25
const OCTAVES = (__CFG__["OCTAVES"] !== undefined ? __CFG__["OCTAVES"] : (3.3)); // フラクタルの重ね合わせ数。多いほど尾根の細部が増える(やや重く なる) 多様化・細かくなる↔安っぽく見える 3~4
const LACUNARITY = (__CFG__["LACUNARITY"] !== undefined ? __CFG__["LACUNARITY"] : (2.0)); // オクターブごとの周波数倍率。大きいほど細かい起伏が乗る 2.0ほぼ固定
const GAIN = (__CFG__["GAIN"] !== undefined ? __CFG__["GAIN"] : (0.4)); // オクターブごとの振幅減衰。大きいほど細部が強く 残る 0.4ほぼ固定
const WARP = (__CFG__["WARP"] !== undefined ? __CFG__["WARP"] : (4.0)); // ドメインワープの最大強さ。大きいほど尾根が 曲がりくねる(RIDGE に比例して効く) 0.7~4.7
const FIELD_AMPLITUDE = (__CFG__["FIELD_AMPLITUDE"] !== undefined ? __CFG__["FIELD_AMPLITUDE"] : (3.0)); // 大きくすると濃淡がはっきり、絵画っぽくなる。フィールド値の振れ幅。等高線(LEVELS)と塗りの濃淡量に対応 2~4
const GRADIENT_AMP = (__CFG__["GRADIENT_AMP"] !== undefined ? __CFG__["GRADIENT_AMP"] : (-6.8)); // 左右の晴れ勾配。負の値 = 左ほど雲が多く右ほど晴れ。0 で勾配なし -6.8:-6.6~-4.8
const DRIFT_SPEED = (__CFG__["DRIFT_SPEED"] !== undefined ? __CFG__["DRIFT_SPEED"] : (0.3)); // 大きくすると雲が右方向への速く移動する。0 で流れなし(うねりのみ) 0.2:0.01
let cleanup = null;
function smoothStep(value) {
return value * value * (3 - 2 * value);
}
function boot() {
cleanup?.();
cleanup = null;
const hero = document.querySelector(".site-home-hero");
if (!(hero instanceof HTMLElement)) {
return;
}
const canvas = hero.querySelector(".site-home-field-canvas");
if (!(canvas instanceof HTMLCanvasElement)) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const radiusSquared = POINTER_RADIUS * POINTER_RADIUS;
const pointer = { x: -9999, y: -9999, tx: -9999, ty: -9999, amp: 0, tamp: 0 };
const fieldSeed = {
phaseX: Math.random() * Math.PI * 2,
phaseY: Math.random() * Math.PI * 2,
scaleX: 0.0100 + Math.random() * 0.003,
// 縦の周波数。scaleX より大きいほど雲が縦に圧縮(横長)に見える。ここでは約1.5倍に設定
scaleY: 0.0100 + Math.random() * 0.003,
};
let devicePixelRatio = 1;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let field = null;
let imprint = null; // マウスの痕跡(フレームをまたいで保持する蓄積レイヤ)
let fillImage = null;
const layout = () => {
devicePixelRatio = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
const bounds = canvas.getBoundingClientRect();
width = Math.max(1, Math.round(bounds.width));
height = Math.max(1, Math.round(bounds.height));
canvas.width = Math.round(width * devicePixelRatio);
canvas.height = Math.round(height * devicePixelRatio);
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
cols = Math.ceil(width / CELL) + 1;
rows = Math.ceil(height / CELL) + 1;
field = new Float32Array(cols * rows);
imprint = new Float32Array(cols * rows); // リサイズ時は痕跡をリセット(グリッド寸法が変わるため)
fillImage = null;
};
// nested-sine を 120°ずつ3方向に配置 → 3回転対称で斜め偏りなし。
// 旧: 2方向だと等値線が45°方向に揃って斜め伸びが出ていた。
const baseNoise = (x, y, t) => {
const p = x;
const q = -0.5 * x + 0.866 * y;
const r = -0.5 * x - 0.866 * y;
return (
Math.sin(p + Math.sin(q * 0.8 + t * 0.50)) +
Math.sin(q + Math.sin(r * 0.8 + t * 0.47)) +
Math.sin(r + Math.sin(p * 0.8 + t * 0.53))
) / 2;
};
// フラクタル(fBm)。オクターブを重ね、各層で円形(なめらか)と尾根(折り返し)を RIDGE で補間。
const fractal = (x, y, t) => {
let amplitude = 1;
let frequency = 1;
let sum = 0;
let norm = 0;
for (let octave = 0; octave < OCTAVES; octave += 1) {
const n = baseNoise(x * frequency, y * frequency, t);
const ridged = 1 - 2 * Math.abs(n); // ゼロ交差に沿って鋭い尾根(折り目)ができる
const shaped = n + (ridged - n) * RIDGE; // RIDGE=0→円形 / RIDGE=1→尾根
sum += shaped * amplitude;
norm += amplitude;
amplitude *= GAIN;
frequency *= LACUNARITY;
}
return sum / norm;
};
const compute = (time) => {
const stamping = pointer.tamp > 0; // 描き込みは滞在中のみ。離れたら即停止し、あとは DECAY に任せる
const warpStrength = WARP * RIDGE; // 円形側ではワープを弱め、山脈側で曲げを強める
for (let gridY = 0; gridY < rows; gridY += 1) {
for (let gridX = 0; gridX < cols; gridX += 1) {
const x = gridX * CELL;
const y = gridY * CELL;
const nx = x * fieldSeed.scaleX + fieldSeed.phaseX - time * DRIFT_SPEED;
const ny = y * fieldSeed.scaleY + fieldSeed.phaseY;
// 低周波のドメインワープで大きな円形の輪郭を崩し、尾根を曲げる
const warpX = warpStrength * baseNoise(nx * 0.4 + 11.5, ny * 0.4, time * 0.2);
const warpY = warpStrength * baseNoise(nx * 0.4, ny * 0.4 + 7.3, time * 0.2);
// 左右の晴れ勾配:左(x=0)で+GRADIENT_AMP/2、右(x=width)で-GRADIENT_AMP/2
const gradient = GRADIENT_AMP * (0.5 - x / width);
const base = FIELD_AMPLITUDE * fractal(nx + warpX, ny + warpY, time) + gradient;
// マウスの痕跡:その場で足すのではなく蓄積バッファへ描き込む。
// 毎フレーム少しだけ自己回復(DECAY)させ、カーソルが居る間は描き込む(GAIN)。
// これで離れても痕跡が残り、放置すればゆっくり癒える。MAX で飽和を防ぐ。
const cell = gridY * cols + gridX;
let mark = imprint[cell] * IMPRINT_DECAY;
if (stamping) {
const dx = x - pointer.x;
const dy = y - pointer.y;
mark +=
pointer.amp * IMPRINT_GAIN * Math.exp(-(dx * dx + dy * dy) / radiusSquared);
if (mark > IMPRINT_MAX) {
mark = IMPRINT_MAX;
}
}
imprint[cell] = mark;
field[cell] = base + mark;
}
}
};
const sampleField = (x, y) => {
const gridX = x / CELL;
const gridY = y / CELL;
const x0 = Math.max(0, Math.min(cols - 2, Math.floor(gridX)));
const y0 = Math.max(0, Math.min(rows - 2, Math.floor(gridY)));
const fx = Math.max(0, Math.min(1, gridX - x0));
const fy = Math.max(0, Math.min(1, gridY - y0));
const corner = y0 * cols + x0;
const topLeft = field[corner];
const topRight = field[corner + 1];
const bottomLeft = field[corner + cols];
const bottomRight = field[corner + cols + 1];
const top = topLeft + (topRight - topLeft) * fx;
const bottom = bottomLeft + (bottomRight - bottomLeft) * fx;
return top + (bottom - top) * fy;
};
const valueToAlpha = (value) => {
const normalized = Math.max(0, Math.min(1, (value - FIELD_MIN) / FIELD_RANGE));
const eased = smoothStep(normalized);
return Math.round((FILL_ALPHA_MIN + eased * (FILL_ALPHA_MAX - FILL_ALPHA_MIN)) * 255);
};
const ensureFillImage = () => {
const pixelWidth = canvas.width;
const pixelHeight = canvas.height;
if (
fillImage &&
fillImage.width === pixelWidth &&
fillImage.height === pixelHeight
) {
return fillImage;
}
fillImage = context.createImageData(pixelWidth, pixelHeight);
return fillImage;
};
const drawFill = () => {
const image = ensureFillImage();
const buffer = image.data;
const pixelWidth = image.width;
const pixelHeight = image.height;
const invDevicePixelRatio = 1 / devicePixelRatio;
const step = FILL_STEP;
for (let pixelY = 0; pixelY < pixelHeight; pixelY += step) {
const y = pixelY * invDevicePixelRatio;
for (let pixelX = 0; pixelX < pixelWidth; pixelX += step) {
const x = pixelX * invDevicePixelRatio;
const alpha = valueToAlpha(sampleField(x, y));
for (let blockY = 0; blockY < step && pixelY + blockY < pixelHeight; blockY += 1) {
const rowStart = ((pixelY + blockY) * pixelWidth + pixelX) * 4;
for (let blockX = 0; blockX < step && pixelX + blockX < pixelWidth; blockX += 1) {
const index = rowStart + blockX * 4;
buffer[index] = FILL_R;
buffer[index + 1] = FILL_G;
buffer[index + 2] = FILL_B;
buffer[index + 3] = alpha;
}
}
}
}
context.putImageData(image, 0, 0);
};
const drawContours = () => {
context.lineWidth = 1.1;
context.lineJoin = "round";
context.lineCap = "round";
context.strokeStyle = `rgba(${STROKE_RGB}, ${STROKE_ALPHA})`;
context.beginPath();
for (let levelIndex = 0; levelIndex < LEVELS.length; levelIndex += 1) {
const threshold = LEVELS[levelIndex];
for (let gridY = 0; gridY < rows - 1; gridY += 1) {
for (let gridX = 0; gridX < cols - 1; gridX += 1) {
const corner = gridY * cols + gridX;
const topLeft = field[corner];
const topRight = field[corner + 1];
const bottomRight = field[corner + cols + 1];
const bottomLeft = field[corner + cols];
const insideTopLeft = topLeft > threshold;
const insideTopRight = topRight > threshold;
const insideBottomRight = bottomRight > threshold;
const insideBottomLeft = bottomLeft > threshold;
const code =
(insideTopLeft ? 8 : 0) |
(insideTopRight ? 4 : 0) |
(insideBottomRight ? 2 : 0) |
(insideBottomLeft ? 1 : 0);
if (code === 0 || code === 15) {
continue;
}
const x0 = gridX * CELL;
const y0 = gridY * CELL;
const x1 = x0 + CELL;
const y1 = y0 + CELL;
const points = [];
if (insideTopLeft !== insideTopRight) {
points.push(x0 + ((threshold - topLeft) / (topRight - topLeft)) * CELL, y0);
}
if (insideTopRight !== insideBottomRight) {
points.push(x1, y0 + ((threshold - topRight) / (bottomRight - topRight)) * CELL);
}
if (insideBottomLeft !== insideBottomRight) {
points.push(
x0 + ((threshold - bottomLeft) / (bottomRight - bottomLeft)) * CELL,
y1,
);
}
if (insideTopLeft !== insideBottomLeft) {
points.push(x0, y0 + ((threshold - topLeft) / (bottomLeft - topLeft)) * CELL);
}
if (points.length === 4) {
context.moveTo(points[0], points[1]);
context.lineTo(points[2], points[3]);
} else if (points.length === 8) {
if (code === 5) {
context.moveTo(points[0], points[1]);
context.lineTo(points[2], points[3]);
context.moveTo(points[4], points[5]);
context.lineTo(points[6], points[7]);
} else {
context.moveTo(points[0], points[1]);
context.lineTo(points[6], points[7]);
context.moveTo(points[2], points[3]);
context.lineTo(points[4], points[5]);
}
}
}
}
}
context.stroke();
};
const draw = () => {
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
drawFill();
if (SHOW_CONTOURS) {
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
drawContours();
}
};
let animationFrame = 0;
let startedAt = null;
let frozenAt = null;
let isVisible = true;
let isDocumentVisible = !document.hidden;
let resizeTimer;
const shouldRun = () => isVisible && isDocumentVisible;
const frame = (timestamp) => {
animationFrame = 0;
if (!shouldRun()) {
return;
}
if (startedAt === null) {
startedAt = timestamp;
}
pointer.x += (pointer.tx - pointer.x) * POINTER_EASE;
pointer.y += (pointer.ty - pointer.y) * POINTER_EASE;
pointer.amp += (pointer.tamp - pointer.amp) * AMP_EASE;
compute(((timestamp - startedAt) / 1000) * TIME_SCALE);
draw();
animationFrame = requestAnimationFrame(frame);
};
const start = () => {
if (animationFrame !== 0 || !shouldRun()) {
return;
}
if (frozenAt !== null && startedAt !== null) {
startedAt += performance.now() - frozenAt;
frozenAt = null;
}
animationFrame = requestAnimationFrame(frame);
};
const stop = () => {
if (animationFrame !== 0) {
cancelAnimationFrame(animationFrame);
animationFrame = 0;
}
if (frozenAt === null) {
frozenAt = performance.now();
}
};
const drawStatic = () => {
layout();
pointer.amp = 0;
compute(0);
draw();
};
const handleResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (reduceMotion) {
drawStatic();
return;
}
layout();
if (!shouldRun()) {
draw();
}
}, 160);
};
const handleVisibilityChange = () => {
isDocumentVisible = !document.hidden;
if (isDocumentVisible && isVisible) {
start();
} else {
stop();
}
};
const handlePointerMove = (event) => {
const heroBounds = hero.getBoundingClientRect();
if (
event.clientX < heroBounds.left ||
event.clientX > heroBounds.right ||
event.clientY < heroBounds.top ||
event.clientY > heroBounds.bottom
) {
pointer.tamp = 0;
return;
}
const bounds = canvas.getBoundingClientRect();
pointer.tx = event.clientX - bounds.left;
pointer.ty = event.clientY - bounds.top;
pointer.tamp = 1;
};
const handlePointerLeave = () => {
pointer.tamp = 0;
};
const handleDocumentPointerMove = (event) => {
handlePointerMove(event);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(hero);
const intersectionObserver = new IntersectionObserver(
(entries) => {
isVisible = entries[0]?.isIntersecting ?? true;
if (isVisible) {
start();
} else {
stop();
}
},
{ threshold: 0 },
);
intersectionObserver.observe(canvas);
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("pointermove", handleDocumentPointerMove);
hero.addEventListener("pointerleave", handlePointerLeave);
window.addEventListener("resize", handleResize);
cleanup = () => {
stop();
clearTimeout(resizeTimer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("pointermove", handleDocumentPointerMove);
hero.removeEventListener("pointerleave", handlePointerLeave);
window.removeEventListener("resize", handleResize);
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
if (reduceMotion) {
drawStatic();
} else {
layout();
compute(0);
draw();
start();
}
}
window.__siteHomeFieldBoot = boot;
window.__siteHomeFieldShutdown = () => {
cleanup?.();
cleanup = null;
};
// 静的ページでは defer 読み込み後にそのまま起動する。
boot();
})();
/* ---- ここまで --------------------------------------------------------- */
(function () {
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
function report() {
document.documentElement.dataset.materialActive = 'true';
document.documentElement.dataset.materialRunning = String(!reduced.matches && !document.hidden);
}
addEventListener('message', function (e) {
var d = e.data || {};
/* 値は起動時に1度だけ読む実装なので、ここでは受け取らない。
親は同じコードで iframe を組み直す(読み込み直しの点滅を起こさない) */
if (d.type === 'material-active') {
/* 元コードは自前の IntersectionObserver で止まる。
画面から外せば、その仕組みがそのまま働く */
var host = document.querySelector('#effect-canvas, #hero-wave');
if (host) host.style.display = d.active === true ? '' : 'none';
document.documentElement.dataset.materialActive = String(d.active === true);
document.documentElement.dataset.materialRunning = String(
d.active === true && !reduced.matches && !document.hidden);
}
});
document.addEventListener('visibilitychange', report);
reduced.addEventListener('change', report);
report();
})();
</script>
</body>
</html>
AIエージェントへ渡す場合は、「このHTMLを対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。
組み込み時の確認#
prefers-reduced-motion: reduceで静止する- 画面外と非表示タブで描画が止まる
- 背景を止めても文字の階層と構図が成立する
- 文字とのコントラストを実際の配色で測る
- 装飾が主要操作の当たり判定を覆わない