フッター掃除マスコット
フッター帯に散らばったゴミを、黄色いローラーがコロコロ転がりながら掃除していきます。メニューボタンはゴミと違って飛び越える。実用UIに1匹だけ住まわせる、動きのあるマスコットの実装パターンです。
ライブデモ
使用例(お題: アイドルグループ Sakura)
この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- Sakura: 公式サイトのフッター。推しマスコットがコロコロ掃除して回る -->
<div class="stage">
<div class="band" data-band aria-label="フッターを掃除して回る桜マスコット">
<div class="nav" aria-hidden="true">
<span class="logo">桜</span><span class="bar"></span><span class="bar"></span>
</div>
<button class="menu" style="left:24%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#FF7DA8"></span></button>
<button class="menu" style="left:50%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#9B6BFF"></span></button>
<button class="menu" style="left:76%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#4D7CFE"></span></button>
<div class="roller" data-roller aria-hidden="true">
<span class="handle"></span>
<span class="wheel" data-wheel><i class="spoke"></i><i class="spoke s2"></i></span>
</div>
</div>
</div>
CSS
:root{ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #160b18; }
.stage {
position: relative; display: flex; align-items: flex-end;
width: 100%; height: 100vh; min-height: 240px; max-height: 100%;
overflow: hidden;
background: linear-gradient(180deg, #20101f 0%, #160b18 100%);
}
.band {
position: relative; width: 100%; height: 90px;
background: #2a1426; overflow: hidden;
border-top: 2px solid rgba(255,125,168,.35);
}
.nav { position: absolute; top: 12px; left: 14px; display: flex; align-items: center; gap: 8px; }
.nav .logo {
width: 22px; height: 22px; border-radius: 6px;
display: grid; place-items: center;
background: #FF7DA8; color: #2a1426; font: 800 14px "Hiragino Mincho ProN", serif;
}
.nav .bar { width: 40px; height: 8px; border-radius: 4px; background: #553a4e; }
.menu {
position: absolute; bottom: 6px; transform: translateX(-50%);
width: 44px; height: 44px; border-radius: 50%;
background: #F2E7EE; border: none; padding: 0;
display: grid; place-items: center;
}
.menu .ic { width: 18px; height: 18px; border-radius: 5px; display: block; }
.trash {
position: absolute; top: 0; left: 0;
border-radius: 50%; background: #9a8a93;
transform-origin: center;
transition: transform .28s var(--ease-spring), opacity .28s ease;
}
.roller { position: absolute; bottom: 8px; left: 0; width: 36px; height: 36px; }
.handle {
position: absolute; left: 22px; bottom: 22px;
width: 6px; height: 34px; border-radius: 3px; background: #8a8a8a;
transform: rotate(28deg); transform-origin: bottom center;
}
.wheel { position: absolute; inset: 0; border-radius: 50%; background: #FFC83D; }
.spoke { position: absolute; left: 50%; top: 4px; width: 2px; height: 28px; margin-left: -1px; background: #B8860B; transform-origin: center; }
.spoke.s2 { transform: rotate(90deg); }
JavaScript
// rAF状態機械(run / jump / pause)。放物線は手書き2次式(デモと同じロジック)。
(() => {
const band = document.querySelector('[data-band]');
if (!band) return;
const roller = band.querySelector('[data-roller]');
const wheel = band.querySelector('[data-wheel]');
const menus = [...band.querySelectorAll('.menu')];
const R = 18, SPEED = 90;
const trash = [];
let buttons = [];
const readButtons = () => buttons = menus.map(m => m.offsetLeft);
const buildTrash = () => {
trash.forEach(t => t.el.remove());
trash.length = 0;
const BW = band.clientWidth;
readButtons();
for (let i = 0; i < 26; i++) {
let x = (i * 137.5) % BW;
for (const b of buttons) { if (Math.abs(x - b) < 34) x = (x + 60) % BW; }
const y = 10 + (i * 53) % 66;
const size = 2 + (i % 3);
const el = document.createElement('span');
el.className = 'trash';
el.style.width = el.style.height = size + 'px';
el.style.transform = `translate(${x}px, ${y}px) scale(1)`;
band.appendChild(el);
trash.push({ el, x, y, alive: true });
}
};
let x = R, dir = 1, rot = 0, jumpY = 0;
let state = 'run', jt = 0, jStart = 0, jEnd = 0, pauseUntil = 0;
let last = 0;
const scatter = () => trash.forEach(t => { t.alive = true; t.el.style.opacity = 1; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(1)`; });
const clean = (t) => { t.alive = false; t.el.style.opacity = 0; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(0)`; };
buildTrash();
window.addEventListener('resize', () => { buildTrash(); x = R; dir = 1; state = 'run'; });
const moveAndRoll = (cx, prevCx) => { rot += (cx - prevCx) / R; };
const loop = (now) => {
if (!last) last = now;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const BW = band.clientWidth;
const maxX = BW - 2 * R;
if (state === 'pause') {
if (now > pauseUntil) { scatter(); state = 'run'; }
} else if (state === 'run') {
const prevCx = x + R;
x += dir * SPEED * dt;
if (x <= 0) { x = 0; dir = 1; }
if (x >= maxX) { x = maxX; dir = -1; }
const cx = x + R;
moveAndRoll(cx, prevCx);
for (const t of trash) { if (t.alive && Math.abs(cx - t.x) < 20) clean(t); }
for (const b of buttons) {
if (Math.abs(cx - b) < 60 && dir * (b - cx) > 0) {
jStart = cx; jEnd = Math.max(R, Math.min(BW - R, b + dir * 60)); jt = 0; state = 'jump';
break;
}
}
if (trash.every(t => !t.alive)) { state = 'pause'; pauseUntil = now + 1000; }
} else if (state === 'jump') {
const prevCx = x + R;
jt = Math.min(jt + dt / 0.6, 1);
const cx = jStart + (jEnd - jStart) * jt;
x = cx - R;
jumpY = -50 * 4 * jt * (1 - jt);
moveAndRoll(cx, prevCx);
if (jt >= 1) { state = 'run'; jumpY = 0; }
}
roller.style.transform = `translate(${x}px, ${jumpY}px)`;
if (wheel) wheel.style.transform = `rotate(${rot}rad)`;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
})();
実装ガイド
使いどころ
実用UIに1匹だけ住まわせる遊びとして。フッターなどの余白に、掃除して回るマスコットで親しみと驚きを添えます。
実装時の注意点
rAFの状態機械(run/jump/pause)で制御します。放物線は手書きの2次式 y=-h*4t(1-t)。ローラーの回転角は走行距離/半径で、反転時は回転方向も反転。掃除はローラーのカラムを通過したゴミを消す方式で全消去を保証し、ボタンは飛び越え、ジャンプ中は掃除判定を切ります。ゴミ配置は固定式(擬似乱数)でリロードしても同じ絵にします。
対応ブラウザ
requestAnimationFrame・transformは全モダンブラウザで安定動作します。座標更新はrAFに限定し(setIntervalでの座標更新は不可)、prefers-reduced-motion時は動きを止める配慮を。対応は実機で確認してください。
よくある失敗
回転方向の反転を忘れると逆回転で進みます。ジャンプ中に掃除判定を切らないと空中でゴミが消えて不自然になります。ボタン直下にゴミを置くと飛び越えて永遠に消えないため、配置時に避けます。Math.randomを使うとリロードで絵が変わります。
応用例
マスコットをブランドキャラに、掃除対象を季節アイコンに、クリックでジャンプ、404やローディングの余白演出などに発展できます。
コード
HTML
<!-- フッター掃除マスコット:黄色いローラーがゴミを掃除し、ボタンは飛び越える -->
<div class="stage">
<div class="band" data-band aria-label="ゴミを掃除して回るフッターのマスコット">
<div class="nav" aria-hidden="true">
<span class="logo"></span><span class="bar"></span><span class="bar"></span>
</div>
<button class="menu" style="left:24%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#FF9EB3"></span></button>
<button class="menu" style="left:50%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#4D7CFE"></span></button>
<button class="menu" style="left:76%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#FF5C5C"></span></button>
<div class="roller" data-roller aria-hidden="true">
<span class="handle"></span>
<span class="wheel" data-wheel><i class="spoke"></i><i class="spoke s2"></i></span>
</div>
</div>
</div>
CSS
:root{
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #F7F5F0; }
.stage {
position: relative;
display: flex;
align-items: flex-end;
width: 100%;
height: 100vh;
min-height: 240px;
max-height: 100%;
overflow: hidden;
background: #F7F5F0;
}
.band {
position: relative;
width: 100%;
height: 90px;
background: #1A1A1A;
overflow: hidden;
}
.nav { position: absolute; top: 12px; left: 14px; display: flex; align-items: center; gap: 8px; }
.nav .logo { width: 18px; height: 18px; border-radius: 5px; background: #FFC83D; }
.nav .bar { width: 40px; height: 8px; border-radius: 4px; background: #5a5a5a; }
.menu {
position: absolute; bottom: 6px; transform: translateX(-50%);
width: 44px; height: 44px; border-radius: 50%;
background: #F2F2F2; border: none; padding: 0;
display: grid; place-items: center;
}
.menu .ic { width: 18px; height: 18px; border-radius: 5px; display: block; }
.trash {
position: absolute; top: 0; left: 0;
border-radius: 50%; background: #9AA0A6;
transform-origin: center;
transition: transform .28s var(--ease-spring), opacity .28s ease;
}
.roller { position: absolute; bottom: 8px; left: 0; width: 36px; height: 36px; }
.handle {
position: absolute; left: 22px; bottom: 22px;
width: 6px; height: 34px; border-radius: 3px; background: #8A8A8A;
transform: rotate(28deg); transform-origin: bottom center;
}
.wheel { position: absolute; inset: 0; border-radius: 50%; background: #FFC83D; }
/* 直径線2本(回転が見えるように) */
.spoke { position: absolute; left: 50%; top: 4px; width: 2px; height: 28px; margin-left: -1px; background: #B8860B; transform-origin: center; }
.spoke.s2 { transform: rotate(90deg); }
JavaScript
// rAF状態機械(run / jump / pause)。放物線は手書き2次式、物理ライブラリ不要。
(() => {
const band = document.querySelector('[data-band]');
if (!band) return; // null安全
const roller = band.querySelector('[data-roller]');
const wheel = band.querySelector('[data-wheel]');
const menus = [...band.querySelectorAll('.menu')];
const R = 18; // ローラー半径
const SPEED = 90; // px/s
const trash = []; // {el, x, y, alive}
let buttons = []; // ボタン中心x(px)
const readButtons = () => buttons = menus.map(m => m.offsetLeft);
// ゴミ26個を固定式で散布(Math.random禁止=リロードで同じ絵)。ボタン直下は避ける。
const buildTrash = () => {
trash.forEach(t => t.el.remove());
trash.length = 0;
const BW = band.clientWidth;
readButtons();
for (let i = 0; i < 26; i++) {
let x = (i * 137.5) % BW;
// ボタン中心±34pxに重なるなら横へ逃がす(地上掃引で必ず消せるように)
for (const b of buttons) { if (Math.abs(x - b) < 34) x = (x + 60) % BW; }
const y = 10 + (i * 53) % 66; // 帯内 10..76
const size = 2 + (i % 3); // 2〜4px
const el = document.createElement('span');
el.className = 'trash';
el.style.width = el.style.height = size + 'px';
el.style.transform = `translate(${x}px, ${y}px) scale(1)`;
band.appendChild(el);
trash.push({ el, x, y, alive: true });
}
};
let x = R, dir = 1, rot = 0, jumpY = 0;
let state = 'run', jt = 0, jStart = 0, jEnd = 0, pauseUntil = 0;
let last = 0;
const scatter = () => trash.forEach(t => { t.alive = true; t.el.style.opacity = 1; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(1)`; });
const clean = (t) => { t.alive = false; t.el.style.opacity = 0; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(0)`; };
buildTrash();
window.addEventListener('resize', () => { buildTrash(); x = R; dir = 1; state = 'run'; });
const moveAndRoll = (cx, prevCx) => { rot += (cx - prevCx) / R; }; // 走行距離/半径、符号は進行方向
const loop = (now) => {
if (!last) last = now;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const BW = band.clientWidth;
const maxX = BW - 2 * R;
if (state === 'pause') {
if (now > pauseUntil) { scatter(); state = 'run'; }
} else if (state === 'run') {
const prevCx = x + R;
x += dir * SPEED * dt;
if (x <= 0) { x = 0; dir = 1; }
if (x >= maxX) { x = maxX; dir = -1; }
const cx = x + R;
moveAndRoll(cx, prevCx);
// 掃除:ローラーのカラムを通過したゴミを消す(地上掃引)
for (const t of trash) { if (t.alive && Math.abs(cx - t.x) < 20) clean(t); }
// ジャンプ発火:ボタン中心60px手前、かつ向かっている時
for (const b of buttons) {
if (Math.abs(cx - b) < 60 && dir * (b - cx) > 0) {
jStart = cx; jEnd = Math.max(R, Math.min(BW - R, b + dir * 60)); jt = 0; state = 'jump';
break;
}
}
if (trash.every(t => !t.alive)) { state = 'pause'; pauseUntil = now + 1000; }
} else if (state === 'jump') {
const prevCx = x + R;
jt = Math.min(jt + dt / 0.6, 1);
const cx = jStart + (jEnd - jStart) * jt;
x = cx - R;
jumpY = -50 * 4 * jt * (1 - jt); // 高さ50px放物線(掃除判定は切る)
moveAndRoll(cx, prevCx);
if (jt >= 1) { state = 'run'; jumpY = 0; }
}
roller.style.transform = `translate(${x}px, ${jumpY}px)`;
if (wheel) wheel.style.transform = `rotate(${rot}rad)`;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「フッター掃除マスコット」の効果を追加してください。
# 追加してほしい効果
フッター掃除マスコット(マイクロインタラクション)
フッター帯に散らばったゴミを、黄色いローラーがコロコロ転がりながら掃除していきます。メニューボタンはゴミと違って飛び越える。実用UIに1匹だけ住まわせる、動きのあるマスコットの実装パターンです。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- フッター掃除マスコット:黄色いローラーがゴミを掃除し、ボタンは飛び越える -->
<div class="stage">
<div class="band" data-band aria-label="ゴミを掃除して回るフッターのマスコット">
<div class="nav" aria-hidden="true">
<span class="logo"></span><span class="bar"></span><span class="bar"></span>
</div>
<button class="menu" style="left:24%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#FF9EB3"></span></button>
<button class="menu" style="left:50%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#4D7CFE"></span></button>
<button class="menu" style="left:76%" type="button" tabindex="-1" aria-hidden="true"><span class="ic" style="background:#FF5C5C"></span></button>
<div class="roller" data-roller aria-hidden="true">
<span class="handle"></span>
<span class="wheel" data-wheel><i class="spoke"></i><i class="spoke s2"></i></span>
</div>
</div>
</div>
【CSS】
:root{
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #F7F5F0; }
.stage {
position: relative;
display: flex;
align-items: flex-end;
width: 100%;
height: 100vh;
min-height: 240px;
max-height: 100%;
overflow: hidden;
background: #F7F5F0;
}
.band {
position: relative;
width: 100%;
height: 90px;
background: #1A1A1A;
overflow: hidden;
}
.nav { position: absolute; top: 12px; left: 14px; display: flex; align-items: center; gap: 8px; }
.nav .logo { width: 18px; height: 18px; border-radius: 5px; background: #FFC83D; }
.nav .bar { width: 40px; height: 8px; border-radius: 4px; background: #5a5a5a; }
.menu {
position: absolute; bottom: 6px; transform: translateX(-50%);
width: 44px; height: 44px; border-radius: 50%;
background: #F2F2F2; border: none; padding: 0;
display: grid; place-items: center;
}
.menu .ic { width: 18px; height: 18px; border-radius: 5px; display: block; }
.trash {
position: absolute; top: 0; left: 0;
border-radius: 50%; background: #9AA0A6;
transform-origin: center;
transition: transform .28s var(--ease-spring), opacity .28s ease;
}
.roller { position: absolute; bottom: 8px; left: 0; width: 36px; height: 36px; }
.handle {
position: absolute; left: 22px; bottom: 22px;
width: 6px; height: 34px; border-radius: 3px; background: #8A8A8A;
transform: rotate(28deg); transform-origin: bottom center;
}
.wheel { position: absolute; inset: 0; border-radius: 50%; background: #FFC83D; }
/* 直径線2本(回転が見えるように) */
.spoke { position: absolute; left: 50%; top: 4px; width: 2px; height: 28px; margin-left: -1px; background: #B8860B; transform-origin: center; }
.spoke.s2 { transform: rotate(90deg); }
【JavaScript】
// rAF状態機械(run / jump / pause)。放物線は手書き2次式、物理ライブラリ不要。
(() => {
const band = document.querySelector('[data-band]');
if (!band) return; // null安全
const roller = band.querySelector('[data-roller]');
const wheel = band.querySelector('[data-wheel]');
const menus = [...band.querySelectorAll('.menu')];
const R = 18; // ローラー半径
const SPEED = 90; // px/s
const trash = []; // {el, x, y, alive}
let buttons = []; // ボタン中心x(px)
const readButtons = () => buttons = menus.map(m => m.offsetLeft);
// ゴミ26個を固定式で散布(Math.random禁止=リロードで同じ絵)。ボタン直下は避ける。
const buildTrash = () => {
trash.forEach(t => t.el.remove());
trash.length = 0;
const BW = band.clientWidth;
readButtons();
for (let i = 0; i < 26; i++) {
let x = (i * 137.5) % BW;
// ボタン中心±34pxに重なるなら横へ逃がす(地上掃引で必ず消せるように)
for (const b of buttons) { if (Math.abs(x - b) < 34) x = (x + 60) % BW; }
const y = 10 + (i * 53) % 66; // 帯内 10..76
const size = 2 + (i % 3); // 2〜4px
const el = document.createElement('span');
el.className = 'trash';
el.style.width = el.style.height = size + 'px';
el.style.transform = `translate(${x}px, ${y}px) scale(1)`;
band.appendChild(el);
trash.push({ el, x, y, alive: true });
}
};
let x = R, dir = 1, rot = 0, jumpY = 0;
let state = 'run', jt = 0, jStart = 0, jEnd = 0, pauseUntil = 0;
let last = 0;
const scatter = () => trash.forEach(t => { t.alive = true; t.el.style.opacity = 1; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(1)`; });
const clean = (t) => { t.alive = false; t.el.style.opacity = 0; t.el.style.transform = `translate(${t.x}px, ${t.y}px) scale(0)`; };
buildTrash();
window.addEventListener('resize', () => { buildTrash(); x = R; dir = 1; state = 'run'; });
const moveAndRoll = (cx, prevCx) => { rot += (cx - prevCx) / R; }; // 走行距離/半径、符号は進行方向
const loop = (now) => {
if (!last) last = now;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const BW = band.clientWidth;
const maxX = BW - 2 * R;
if (state === 'pause') {
if (now > pauseUntil) { scatter(); state = 'run'; }
} else if (state === 'run') {
const prevCx = x + R;
x += dir * SPEED * dt;
if (x <= 0) { x = 0; dir = 1; }
if (x >= maxX) { x = maxX; dir = -1; }
const cx = x + R;
moveAndRoll(cx, prevCx);
// 掃除:ローラーのカラムを通過したゴミを消す(地上掃引)
for (const t of trash) { if (t.alive && Math.abs(cx - t.x) < 20) clean(t); }
// ジャンプ発火:ボタン中心60px手前、かつ向かっている時
for (const b of buttons) {
if (Math.abs(cx - b) < 60 && dir * (b - cx) > 0) {
jStart = cx; jEnd = Math.max(R, Math.min(BW - R, b + dir * 60)); jt = 0; state = 'jump';
break;
}
}
if (trash.every(t => !t.alive)) { state = 'pause'; pauseUntil = now + 1000; }
} else if (state === 'jump') {
const prevCx = x + R;
jt = Math.min(jt + dt / 0.6, 1);
const cx = jStart + (jEnd - jStart) * jt;
x = cx - R;
jumpY = -50 * 4 * jt * (1 - jt); // 高さ50px放物線(掃除判定は切る)
moveAndRoll(cx, prevCx);
if (jt >= 1) { state = 'run'; jumpY = 0; }
}
roller.style.transform = `translate(${x}px, ${jumpY}px)`;
if (wheel) wheel.style.transform = `rotate(${rot}rad)`;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。