motion-background について
motion-backgroundは、コンテンツの背後に置くアニメーション背景を作るスキルです。CSS、GLSL、Canvasの手法を扱い、シームレスなループ、文字の読みやすさ、動きの設定、性能制御を重視します。
主な機能
- 01CSSグラデーションとキーフレームアニメーション
- 02Three.jsのGLSLフローシェーダー
- 03Canvasのパーティクルと接続線
- 04シームレスループとフレーム固定
- 05動きを抑える設定と一時停止制御
活用シーン
- 01ランディングページに控えめな動きを追加
- 02ログイン画面や空状態に背景演出を追加
- 03オーロラ、メッシュ、粒子背景の作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ iart-ai/ motion-design-skills --skill motion-backgroundインストール後、Claude Code で/motion-backgroundを入力して読み込まれたか確認します。
同梱ファイルREADME.mdreferences/background-recipes.md
詳細
モーション背景
コンテンツの背後に配置し、視線を奪わない、生き生きとしたアンビエント背景です。目的は、読みやすさとパフォーマンスを保ちながら、控えめでシームレスにループする動きを実現することです。このスキルは自己完結しており、以下の各手法には実行可能なコードが含まれています。シェーダーを使う場合の Three.js 以外、外部依存関係はありません。
使用する場面
- さりげなく動くヒーロー/ランディング背景、セクションのアンビエンス。
- ログイン画面/スプラッシュ画面/空の状態に使う、生き生きとした背景。
- テキストや UI の背後に置く、グラデーション、メッシュ、オーロラ、シェーダー、またはパーティクル/星座のフィールド。
アプローチを選ぶ
| 見た目 | 手法 | コスト |
|---|---|---|
| 柔らかなアニメーション付きグラデーション/メッシュ | CSS グラデーション + キーフレーム | 最小、JS 不要 |
| 流れるオーロラ/有機的なノイズ | GLSL フラグメントシェーダー(Three.js のフルスクリーンクワッド) | GPU、中程度 |
| 星座/漂うドット | Canvas 2D パーティクル | CPU、数に応じて増加 |
| 奥行き/視差 | マウスに応じたオフセットを加えた同じシェーダー/パーティクル | 中程度 |
グラデーションで十分な場合は、デフォルトで CSS を使ってください。最も軽量で、信頼性も高いためです。有機的に流れる色にはシェーダーを、星座/ネットワークのような見た目には Canvas パーティクルを使ってください。
デザインルール
- 控えめ:コンテンツとのコントラストを低くし、動きはゆっくりにします(長い周期、8〜30 秒のループ)。テキストと競合する要素は避けてください。
- 読みやすさ:前景のテキストに十分なコントラストを確保します。必要に応じてスクリーン(
linear-gradientオーバーレイまたはbackdrop)を追加してください。 - シームレスなループ:周期関数を使って動きを制御し、時間が折り返しても目に見える段差が生じないようにします(下記参照)。
- 動きの設定とバッテリーに配慮:
prefers-reduced-motionを尊重し、画面外またはタブが非表示のときは一時停止してください。
基本的な手法(コードを直接記載、実行可能)
1. CSS アニメーション付きメッシュグラデーション(JS 不要)
位置がゆっくり変化する、複数レイヤーの放射状グラデーションです。ビューポートより大きなグラデーションの background-position をアニメーションさせると、シームレスにループします。
.bg {
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(40% 50% at 20% 30%, #5b8cff55, transparent 60%),
radial-gradient(45% 55% at 80% 20%, #b05bff55, transparent 60%),
radial-gradient(50% 60% at 50% 80%, #2de1c255, transparent 60%),
#0b0e1a;
background-size: 200% 200%;
animation: meshmove 24s ease-in-out infinite;
}
@keyframes meshmove {
0%, 100% { background-position: 0% 0%, 100% 0%, 50% 100%; }
50% { background-position: 30% 20%, 70% 30%, 60% 70%; }
}
@media (prefers-reduced-motion: reduce) {
.bg { animation: none; }
}
0% と 100% のキーフレームが同一なので、ループに継ぎ目はありません。
2. フルスクリーン GLSL グラデーション + ノイズシェーダー(Three.js)
フルスクリーンの平面上で、フラグメントシェーダーのバリューノイズを使って流れるオーロラ/グラデーションを描画します。uTime は各フレームで進みます。ループさせるには、ラップした時間を渡します(セクション 4)。
import * as THREE from 'three';
const canvas = document.querySelector('#bg');
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for perf
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: {value: 0},
uRes: {value: new THREE.Vector2()},
uColorA: {value: new THREE.Color('#5b8cff')},
uColorB: {value: new THREE.Color('#b05bff')},
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`,
fragmentShader: `
precision highp float;
uniform float uTime; uniform vec2 uRes;
uniform vec3 uColorA, uColorB;
// hash + value noise
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float noise(vec2 p){
vec2 i = floor(p), f = fract(p);
float a = hash(i), b = hash(i+vec2(1,0));
float c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
vec2 u = f*f*(3.0-2.0*f);
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
}
float fbm(vec2 p){
float v=0.0, a=0.5;
for(int i=0;i<5;i++){ v += a*noise(p); p*=2.0; a*=0.5; }
return v;
}
void main(){
vec2 uv = gl_FragCoord.xy / uRes.xy;
float n = fbm(uv*3.0 + vec2(uTime*0.05, uTime*0.03));
vec3 col = mix(uColorA, uColorB, smoothstep(0.2, 0.8, n + uv.y*0.3));
gl_FragColor = vec4(col, 1.0);
}`,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
scene.add(quad);
function resize(){
renderer.setSize(window.innerWidth, window.innerHeight);
uniforms.uRes.value.set(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', resize); resize();
const clock = new THREE.Clock();
let running = true;
function loop(){
if (running){
uniforms.uTime.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
requestAnimationFrame(loop);
}
loop();
3. Canvas の星座パーティクル
漂う点が近づくと線でつながる、定番の「ネットワーク」背景です。依存関係のない、純粋な Canvas 2D です。
const canvas = document.querySelector('#stars');
const ctx = canvas.getContext('2d');
let W, H, pts;
const COUNT = 80, LINK = 120;
function init(){
W = canvas.width = innerWidth; H = canvas.height = innerHeight;
pts = Array.from({length: COUNT}, () => ({
x: Math.random()*W, y: Math.random()*H,
vx: (Math.random()-0.5)*0.3, vy: (Math.random()-0.5)*0.3,
}));
}
addEventListener('resize', init); init();
function frame(){
ctx.clearRect(0, 0, W, H);
for (const p of pts){
p.x += p.vx; p.y += p.vy;
if (p.x<0||p.x>W) p.vx*=-1;
if (p.y<0||p.y>H) p.vy*=-1;
ctx.fillStyle = '#9db4ff'; ctx.fillRect(p.x, p.y, 2, 2);
}
for (let i=0;i<COUNT;i++) for (let j=i+1;j<COUNT;j++){
const dx=pts[i].x-pts[j].x, dy=pts[i].y-pts[j].y;
const d=Math.hypot(dx, dy);
if (d<LINK){
ctx.strokeStyle = `rgba(157,180,255,${1-d/LINK})`;
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
}
}
requestAnimationFrame(frame);
}
frame();
O(n²) のリンク処理ループは約 120 個までは問題ありません。それを超える場合は、空間ハッシュを使ってグリッドに分割し、隣接するセルだけを判定してください。
4. シームレスなループの手法
Canvas/JS では、時間を固定周期内に折り返し、動きを正確に繰り返すようにします。周期関数の引数にはループの位相(0→2π)を使います。
const PERIOD = 12; // seconds
const phase = (t % PERIOD) / PERIOD * Math.PI * 2;
const offset = Math.sin(phase) * amp; // returns to start at t = PERIOD
phase(またはその整数倍)の sin/cos だけで構成した動きは、シームレスにループします。シェーダーでは uTime = phase を渡し、その値の sin/cos だけを使います。ノイズをスクロールするグラデーションでは、周期ごとにノイズのセルを整数個分スクロールさせ、フィールドがタイル状につながるようにします。
5. 動きの軽減設定 + 画面外/非表示時の一時停止
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
running = false; // render one static frame, then stop
renderer.render(scene, camera);
}
// Pause when the tab is hidden
document.addEventListener('visibilitychange', () => {
running = !document.hidden && !reduce;
});
// Pause when the canvas scrolls offscreen
new IntersectionObserver(([e]) => {
running = e.isIntersecting && !document.hidden && !reduce;
}).observe(canvas);
running が false のときは、rAF ループ内のレンダリングをスキップしてください(セクション 2 のようにします)。すぐに再開できるようループ自体は維持しますが、GPU/CPU の処理は行いません。
配信と検証(スタンドアロン HTML)
パッケージ化されたヘルパー(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3は?t=Nハーネスを固定し、各時点のスクリーンショットを撮影します。scripts/contact-sheet.sh sheet.png frame-*.pngは、それらをタイル状に並べてひと目で確認できるようにします。詳しくはscripts/README.mdを参照してください。
モーション背景は、ブラウザーで直接開ける単一の .html ファイルとして提供します。マークアップ、背景、そして(シェーダー/canvas の場合は)CDN から読み込む Three.js または canvas JS がすべてインラインに含まれます。単一ファイルが適切な構成です。
出力の要件:
.htmlファイルを1つ用意します。背景レイヤーに加えて、読みやすさとコントラストを確認するため、前景テキストのサンプルを重ねます。- アニメーションの駆動元は1つだけにします。CSS の
@keyframes、シェーダーの rAF ループ、canvas の rAF ループのいずれか1つです。 - 手法に合わせて、以下のフリーズハーネスを含め、任意の時点を決定論的にスクリーンショットできるようにします。
フリーズハーネス — スクリーンショット用にフレームを固定します。 手法に応じた仕組みを使います。
<script>
const t = new URLSearchParams(location.search).get("t");
if (t !== null) {
const T = parseFloat(t);
// CSS mesh gradient:
document.querySelectorAll(".bg").forEach(el => {
el.style.animationDelay = (-T) + "s";
el.style.animationPlayState = "paused";
});
// GLSL shader instead? → render exactly one frame at fixed time:
// uniforms.uTime.value = T; renderer.render(scene, camera); running = false;
// Canvas particles? → seed deterministically, step the sim to T, draw once, stop the loop.
}
window.__ready = true; // ready signal for headless wait
</script>
検証の流れ — レンダリング → 固定 → スクリーンショット → 確認:
- 1ループ周期の開始/中間/終了時点でファイルを固定して開きます。
…/bg.html?t=0、?t=<period/2>、?t=<period>。 - 固定した各フレームのスクリーンショットを撮影します。
- 忠実度(控えめで、ブランドに合い、シームレスか)とアーティファクトを確認します。すべてのフレームでテキストのコントラストが保たれていること、グラデーション/シェーダーにバンディングがないこと、ループの継ぎ目(
t=0とt=period)が一致すること、GPU/コンソールエラーがないことを確認します。
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/bg.html?t=12" frame-mid.png
完了前の確認事項:
- ブラウザーでスタンドアロン動作すること — コンソール/WebGL エラーや CDN の読み込み漏れがないこと。
- 駆動元が1つであること。
?t=Nで正確なフレームが固定されること(シェーダーは1フレームをレンダリングし、canvas のシミュレーションは決定論的にステップします)。 - 開始/中間/終了時点のスクリーンショットを撮影し、前景テキストが読みやすく、バンディングがなく、ループの継ぎ目が見えないこと。
prefers-reduced-motionに対応していること(静止フレームを1つレンダリングし、ループを停止する)。また、画面外/非表示時に一時停止すること。devicePixelRatioを2以下に制限すること。動きは遅く控えめにし、コンテンツより強いコントラストにならないようにします。
クイックリファレンス
| 必要なもの | 方法 |
|---|---|
| 最も手軽なグラデーション | CSS のレイヤー化した radial-gradient と background-position のキーフレーム |
| 有機的な流れ | GLSL の fbm ノイズと、スクロールする uTime |
| ネットワーク/ドット | canvas のパーティクルと、距離に応じて結ぶ線 |
| 継ぎ目をなくす | sin/cos(phase) で動きを駆動し、最初と最後のキーフレームを一致させる |
| パフォーマンスの上限 | setPixelRatio(min(dpr, 2)) を使い、更新を間引き、モバイルではパーティクル数を減らす |
| アクセシビリティ | prefers-reduced-motion による静止フォールバック |
| バッテリーの節約 | visibilitychange と IntersectionObserver で一時停止する |
注意点
- 背景のコントラストを前景テキストより強くしないでください。強くなる場合はスクリーンを重ねます。
- Retina/4K で
devicePixelRatioを制限しないと、GPU に大きな負荷がかかります。上限を2にしてください。 background-positionのループがシームレスになるのは、最初と最後のキーフレームが完全に一致する場合だけです。- ノイズをスクロールするシェーダーがループするのは、周期内のスクロール量がセルの整数個分になる場合だけです。そうでないと、ループ時に目に見えるジャンプが発生します。
- 画面外/非表示時の一時停止を忘れると、ユーザーに見えないときでもモバイルでバッテリーを消耗します。
参考ファイル
references/background-recipes.md— より充実した、そのまま組み込める実装を紹介しています。ぼかしを加えた、より豊かな複数ブロブの CSS メッシュ、マウスパララックスと真にシームレスな時間の折り返し処理を備えた完全な Three.js オーロラシェーダー、グリッドに最適化された、マウスの反発を備える星座フィールド、完全にループするノイズフロー手法、そしてこれらすべてを包む、動きを抑える設定と画面外での一時停止を完全に管理する仕組みが含まれます。