画像トレイル
マウス移動の軌跡に沿って画像が次々に出現し、短時間でフェード消滅する Codrops 定番のイメージトレイル。移動量が閾値を超えるたびに次の画像を循環表示します。ポートフォリオやヒーローの没入演出に最適です。
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW: カフェの世界観ギャラリー。マウス軌跡に店内写真が湧き出す演出 -->
<div class="trail-stage" data-trail-root>
<header class="cafe-bar">
<span class="cafe-logo"><span class="cafe-cup">☕</span>MOON BREW</span>
<span class="cafe-sub">GALLERY</span>
</header>
<div class="caption">
<h1 class="title">月夜のカフェへ、<br>ようこそ。</h1>
<p class="lead">画面をなぞると、店内の風景がふわりと現れます。</p>
</div>
<!-- 画像はJSがプールとして生成・循環表示 -->
</div>
CSS
/* MOON BREW カフェギャラリー: クリーム地に店内写真がトレイルで湧き出す */
* { box-sizing: border-box; }
/* iframe全面を塗る(一覧でも白抜けしない) */
html, body {
margin: 0;
width: 100%;
min-height: 100%;
background: #f5ede1;
}
.trail-stage {
position: relative;
width: 100%;
min-height: 360px;
height: 100vh;
max-height: 100%;
overflow: hidden;
background:
radial-gradient(700px 420px at 50% 38%, #fbf4e8, #f0e4d2),
#f5ede1;
font-family: "Hiragino Sans", "Yu Gothic", system-ui, sans-serif;
color: #2b1d12;
cursor: crosshair;
}
/* ヘッダー */
.cafe-bar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 2;
display: flex;
align-items: baseline;
gap: 14px;
padding: 15px 26px;
pointer-events: none;
}
.cafe-logo {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 800;
font-size: 16px;
letter-spacing: .04em;
}
.cafe-cup { font-size: 18px; }
.cafe-sub {
font-size: 11px;
font-weight: 700;
letter-spacing: .24em;
color: #b89a76;
}
/* 中央キャプション(画像より下のレイヤー) */
.caption {
position: absolute;
inset: 0;
z-index: 0;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
color: #2b1d12;
pointer-events: none;
user-select: none;
}
.caption .title {
margin: 0 0 12px;
font-size: clamp(26px, 5.5vw, 40px);
font-weight: 900;
letter-spacing: .04em;
line-height: 1.3;
}
.caption .lead { margin: 0; font-size: 13px; color: #6b5640; }
/* トレイル画像: JSが座標を設定し、追加クラスでアニメ */
.trail-img {
position: absolute;
top: 0; left: 0;
width: 120px;
height: 84px;
margin: -42px 0 0 -60px; /* 中心基準に配置 */
object-fit: cover;
border-radius: 10px;
box-shadow: 0 12px 30px rgba(43,29,18,.3);
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
z-index: 1;
border: 3px solid #fff;
transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot));
}
/* 出現アニメ: ぽんと現れてゆっくりフェード消滅 */
.trail-img.show {
animation: trailPop var(--life, 900ms) cubic-bezier(.2, .8, .2, 1) forwards;
}
@keyframes trailPop {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot)); }
18% { opacity: 1; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
100% { opacity: 0; transform: translate3d(var(--x), calc(var(--y) + 26px), 0) scale(.92) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.trail-img.show { animation: trailFade var(--life, 700ms) linear forwards; }
@keyframes trailFade {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
20% { opacity: .9; }
100% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
}
}
JavaScript
// MOON BREW: 軌跡に店内写真が湧き出す画像トレイル。待機中は自動巡回、操作で本物に追従
(() => {
const root = document.querySelector('[data-trail-root]');
if (!root) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// カフェ風の写真URL(循環使用)
const SRCS = [201, 225, 240, 292, 312, 326, 365, 431].map(
(n) => `https://picsum.photos/240/168?random=${n}`
);
// 画像要素のプールを事前生成
const POOL = SRCS.length;
const pool = [];
for (let i = 0; i < POOL; i++) {
const img = document.createElement('img');
img.className = 'trail-img';
img.src = SRCS[i];
img.alt = '';
img.loading = 'eager';
img.decoding = 'async';
img.draggable = false;
root.appendChild(img);
pool.push(img);
}
let index = 0; // 次に使う画像(循環)
// 1枚出現させる
const spawn = (x, y) => {
const img = pool[index % POOL];
index++;
const life = reduce ? 700 : 760 + Math.random() * 380;
const rot = (Math.random() * 16 - 8).toFixed(1);
img.style.setProperty('--x', `${x}px`);
img.style.setProperty('--y', `${y}px`);
img.style.setProperty('--rot', `${rot}deg`);
img.style.setProperty('--life', `${life}ms`);
// アニメ再起動
img.classList.remove('show');
void img.offsetWidth; // 強制リフロー
img.classList.add('show');
};
// reduced-motion: 中央付近に静的に数枚並べてデモ内容を提示
if (reduce) {
const place = () => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const offs = [[-130, -10], [0, 12], [130, -6]];
offs.forEach((o, i) => {
const img = pool[i % POOL];
img.style.setProperty('--x', `${cx + o[0]}px`);
img.style.setProperty('--y', `${cy + o[1]}px`);
img.style.setProperty('--rot', '0deg');
img.style.opacity = '1';
img.style.transform =
`translate3d(${cx + o[0]}px, ${cy + o[1]}px, 0) scale(1) rotate(0deg)`;
});
};
place();
return;
}
const THRESHOLD = 48; // 移動量しきい値
let last = null;
let dist = 0;
let usePointer = false;
let lastMove = 0;
const IDLE = 1600;
// 移動量に応じて画像を出す
const advance = (x, y) => {
if (last) {
dist += Math.hypot(x - last.x, y - last.y);
while (dist >= THRESHOLD) {
spawn(x, y);
dist -= THRESHOLD;
}
}
last = { x, y };
};
// 仮想カーソルの自動経路(リサージュ)
const autoPos = (t) => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const ax = r.width * 0.30, ay = r.height * 0.26;
return {
x: cx + Math.sin(t * 0.00060) * ax,
y: cy + Math.sin(t * 0.00097 + 1.1) * ay,
};
};
root.addEventListener('pointermove', (e) => {
const r = root.getBoundingClientRect();
const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);
const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);
if (!usePointer) { usePointer = true; last = null; dist = 0; }
lastMove = performance.now();
advance(x, y);
});
const loop = (now) => {
if (usePointer && now - lastMove > IDLE) { usePointer = false; last = null; dist = 0; }
if (!usePointer) {
const p = autoPos(now);
advance(p.x, p.y);
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
})();
コード
HTML
<!-- 画像トレイル:マウス軌跡に沿って画像が出現し短時間でフェード消滅 -->
<div class="trail-stage" data-trail-root>
<div class="caption">
<h1 class="title">IMAGE TRAIL</h1>
<p class="lead">マウスを動かすと、軌跡に画像が湧き出します。</p>
</div>
<!-- 画像はJSがプールとして生成・循環表示する -->
</div>
CSS
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
margin: 0;
width: 100%;
min-height: 100%;
background: #0a0b12;
}
.trail-stage {
position: relative;
width: 100%;
min-height: 360px;
height: 100vh;
max-height: 100%;
overflow: hidden;
background:
radial-gradient(700px 420px at 50% 40%, #1c1f33, #0a0b12);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
cursor: crosshair;
}
/* 中央キャプション(画像より下のレイヤー) */
.caption {
position: absolute;
inset: 0;
z-index: 0;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
color: #eef1ff;
pointer-events: none;
user-select: none;
}
.caption .title {
margin: 0 0 10px;
font-size: clamp(30px, 6.5vw, 52px);
font-weight: 900;
letter-spacing: .1em;
}
.caption .lead { margin: 0; font-size: 14px; color: rgba(238,241,255,.7); }
/* トレイル画像:JSが座標を設定し、追加クラスでアニメ */
.trail-img {
position: absolute;
top: 0; left: 0;
width: 120px;
height: 84px;
margin: -42px 0 0 -60px; /* 中心基準に配置 */
object-fit: cover;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,.45);
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
z-index: 1;
/* CSS変数で出現位置と初期回転を受け取る */
transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot));
}
/* 出現アニメ:ぽんと現れてゆっくりフェード消滅 */
.trail-img.show {
animation: trailPop var(--life, 900ms) cubic-bezier(.2, .8, .2, 1) forwards;
}
@keyframes trailPop {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot)); }
18% { opacity: 1; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
100% { opacity: 0; transform: translate3d(var(--x), calc(var(--y) + 26px), 0) scale(.92) rotate(0deg); }
}
/* モーション控えめ:アニメせず一瞬だけ薄く表示してすぐ消す */
@media (prefers-reduced-motion: reduce) {
.trail-img.show { animation: trailFade var(--life, 700ms) linear forwards; }
@keyframes trailFade {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
20% { opacity: .9; }
100% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
}
}
JavaScript
// 画像トレイル:仮想カーソルの自動軌跡に沿って画像を出現させ、操作時は本物に追従
(() => {
const root = document.querySelector('[data-trail-root]');
if (!root) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// picsumの複数画像URL(循環使用)
const SRCS = [101, 102, 103, 104, 106, 108, 110, 112].map(
(n) => `https://picsum.photos/240/168?random=${n}`
);
// 画像要素のプールを事前生成(DOM生成コストを抑える)
const POOL = SRCS.length;
const pool = [];
for (let i = 0; i < POOL; i++) {
const img = document.createElement('img');
img.className = 'trail-img';
img.src = SRCS[i];
img.alt = '';
img.loading = 'eager';
img.decoding = 'async';
img.draggable = false;
root.appendChild(img);
pool.push(img);
}
let index = 0; // 次に使う画像(循環)
// 1枚出現させる
const spawn = (x, y) => {
const img = pool[index % POOL];
index++;
// ランダムな寿命と初期回転で単調さを回避
const life = reduce ? 700 : 760 + Math.random() * 380;
const rot = (Math.random() * 16 - 8).toFixed(1);
img.style.setProperty('--x', `${x}px`);
img.style.setProperty('--y', `${y}px`);
img.style.setProperty('--rot', `${rot}deg`);
img.style.setProperty('--life', `${life}ms`);
// アニメ再起動:一旦クラスを外し、リフロー後に付け直す
img.classList.remove('show');
void img.offsetWidth; // 強制リフロー
img.classList.add('show');
};
// reduced-motion:自動巡回せず中央付近に静的に数枚並べてデモ内容を提示
if (reduce) {
const place = () => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const offs = [[-130, -10], [0, 12], [130, -6]];
offs.forEach((o, i) => {
const img = pool[i % POOL];
img.style.setProperty('--x', `${cx + o[0]}px`);
img.style.setProperty('--y', `${cy + o[1]}px`);
img.style.setProperty('--rot', '0deg');
img.style.opacity = '1';
img.style.transform =
`translate3d(${cx + o[0]}px, ${cy + o[1]}px, 0) scale(1) rotate(0deg)`;
});
};
place();
return;
}
const THRESHOLD = 48; // 移動量しきい値:これを超えたら次の画像を出す
let last = null; // 直近の座標(本物/仮想共通)
let dist = 0; // 累積移動量
let usePointer = false; // 本物のポインタ追従中か
let lastMove = 0; // 最後にポインタが動いた時刻
let raf = 0;
const IDLE = 1600; // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)
// 移動した分だけ画像を出す(速い動きでも取りこぼさない)
const advance = (x, y) => {
if (last) {
dist += Math.hypot(x - last.x, y - last.y);
while (dist >= THRESHOLD) {
spawn(x, y);
dist -= THRESHOLD;
}
}
last = { x, y };
};
// 仮想カーソルの自動経路(リサージュ):軌跡に沿って画像が湧き出す
const autoPos = (t) => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const ax = r.width * 0.30, ay = r.height * 0.26;
return {
x: cx + Math.sin(t * 0.00060) * ax,
y: cy + Math.sin(t * 0.00097 + 1.1) * ay,
};
};
// pointermoveで本物の座標を採用
root.addEventListener('pointermove', (e) => {
const r = root.getBoundingClientRect();
const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);
const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);
if (!usePointer) { usePointer = true; last = null; dist = 0; } // 切替時は累積リセット
lastMove = performance.now();
advance(x, y);
});
// メインループ:アイドル中は自動軌跡で画像を出す
const loop = (now) => {
if (usePointer && now - lastMove > IDLE) { usePointer = false; last = null; dist = 0; }
if (!usePointer) {
const p = autoPos(now);
advance(p.x, p.y);
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「画像トレイル」の効果を追加してください。
# 追加してほしい効果
画像トレイル(カスタムカーソル)
マウス移動の軌跡に沿って画像が次々に出現し、短時間でフェード消滅する Codrops 定番のイメージトレイル。移動量が閾値を超えるたびに次の画像を循環表示します。ポートフォリオやヒーローの没入演出に最適です。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 画像トレイル:マウス軌跡に沿って画像が出現し短時間でフェード消滅 -->
<div class="trail-stage" data-trail-root>
<div class="caption">
<h1 class="title">IMAGE TRAIL</h1>
<p class="lead">マウスを動かすと、軌跡に画像が湧き出します。</p>
</div>
<!-- 画像はJSがプールとして生成・循環表示する -->
</div>
【CSS】
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
margin: 0;
width: 100%;
min-height: 100%;
background: #0a0b12;
}
.trail-stage {
position: relative;
width: 100%;
min-height: 360px;
height: 100vh;
max-height: 100%;
overflow: hidden;
background:
radial-gradient(700px 420px at 50% 40%, #1c1f33, #0a0b12);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
cursor: crosshair;
}
/* 中央キャプション(画像より下のレイヤー) */
.caption {
position: absolute;
inset: 0;
z-index: 0;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
color: #eef1ff;
pointer-events: none;
user-select: none;
}
.caption .title {
margin: 0 0 10px;
font-size: clamp(30px, 6.5vw, 52px);
font-weight: 900;
letter-spacing: .1em;
}
.caption .lead { margin: 0; font-size: 14px; color: rgba(238,241,255,.7); }
/* トレイル画像:JSが座標を設定し、追加クラスでアニメ */
.trail-img {
position: absolute;
top: 0; left: 0;
width: 120px;
height: 84px;
margin: -42px 0 0 -60px; /* 中心基準に配置 */
object-fit: cover;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,.45);
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
z-index: 1;
/* CSS変数で出現位置と初期回転を受け取る */
transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot));
}
/* 出現アニメ:ぽんと現れてゆっくりフェード消滅 */
.trail-img.show {
animation: trailPop var(--life, 900ms) cubic-bezier(.2, .8, .2, 1) forwards;
}
@keyframes trailPop {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(.6) rotate(var(--rot)); }
18% { opacity: 1; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
100% { opacity: 0; transform: translate3d(var(--x), calc(var(--y) + 26px), 0) scale(.92) rotate(0deg); }
}
/* モーション控えめ:アニメせず一瞬だけ薄く表示してすぐ消す */
@media (prefers-reduced-motion: reduce) {
.trail-img.show { animation: trailFade var(--life, 700ms) linear forwards; }
@keyframes trailFade {
0% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
20% { opacity: .9; }
100% { opacity: 0; transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
}
}
【JavaScript】
// 画像トレイル:仮想カーソルの自動軌跡に沿って画像を出現させ、操作時は本物に追従
(() => {
const root = document.querySelector('[data-trail-root]');
if (!root) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// picsumの複数画像URL(循環使用)
const SRCS = [101, 102, 103, 104, 106, 108, 110, 112].map(
(n) => `https://picsum.photos/240/168?random=${n}`
);
// 画像要素のプールを事前生成(DOM生成コストを抑える)
const POOL = SRCS.length;
const pool = [];
for (let i = 0; i < POOL; i++) {
const img = document.createElement('img');
img.className = 'trail-img';
img.src = SRCS[i];
img.alt = '';
img.loading = 'eager';
img.decoding = 'async';
img.draggable = false;
root.appendChild(img);
pool.push(img);
}
let index = 0; // 次に使う画像(循環)
// 1枚出現させる
const spawn = (x, y) => {
const img = pool[index % POOL];
index++;
// ランダムな寿命と初期回転で単調さを回避
const life = reduce ? 700 : 760 + Math.random() * 380;
const rot = (Math.random() * 16 - 8).toFixed(1);
img.style.setProperty('--x', `${x}px`);
img.style.setProperty('--y', `${y}px`);
img.style.setProperty('--rot', `${rot}deg`);
img.style.setProperty('--life', `${life}ms`);
// アニメ再起動:一旦クラスを外し、リフロー後に付け直す
img.classList.remove('show');
void img.offsetWidth; // 強制リフロー
img.classList.add('show');
};
// reduced-motion:自動巡回せず中央付近に静的に数枚並べてデモ内容を提示
if (reduce) {
const place = () => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const offs = [[-130, -10], [0, 12], [130, -6]];
offs.forEach((o, i) => {
const img = pool[i % POOL];
img.style.setProperty('--x', `${cx + o[0]}px`);
img.style.setProperty('--y', `${cy + o[1]}px`);
img.style.setProperty('--rot', '0deg');
img.style.opacity = '1';
img.style.transform =
`translate3d(${cx + o[0]}px, ${cy + o[1]}px, 0) scale(1) rotate(0deg)`;
});
};
place();
return;
}
const THRESHOLD = 48; // 移動量しきい値:これを超えたら次の画像を出す
let last = null; // 直近の座標(本物/仮想共通)
let dist = 0; // 累積移動量
let usePointer = false; // 本物のポインタ追従中か
let lastMove = 0; // 最後にポインタが動いた時刻
let raf = 0;
const IDLE = 1600; // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)
// 移動した分だけ画像を出す(速い動きでも取りこぼさない)
const advance = (x, y) => {
if (last) {
dist += Math.hypot(x - last.x, y - last.y);
while (dist >= THRESHOLD) {
spawn(x, y);
dist -= THRESHOLD;
}
}
last = { x, y };
};
// 仮想カーソルの自動経路(リサージュ):軌跡に沿って画像が湧き出す
const autoPos = (t) => {
const r = root.getBoundingClientRect();
const cx = r.width / 2, cy = r.height / 2;
const ax = r.width * 0.30, ay = r.height * 0.26;
return {
x: cx + Math.sin(t * 0.00060) * ax,
y: cy + Math.sin(t * 0.00097 + 1.1) * ay,
};
};
// pointermoveで本物の座標を採用
root.addEventListener('pointermove', (e) => {
const r = root.getBoundingClientRect();
const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);
const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);
if (!usePointer) { usePointer = true; last = null; dist = 0; } // 切替時は累積リセット
lastMove = performance.now();
advance(x, y);
});
// メインループ:アイドル中は自動軌跡で画像を出す
const loop = (now) => {
if (usePointer && now - lastMove > IDLE) { usePointer = false; last = null; dist = 0; }
if (!usePointer) {
const p = autoPos(now);
advance(p.x, p.y);
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。