ショート動画でよく見る「ポーズ合わせ演出」をJavaScriptで再現する方法
連日、Googleの検索トップで見かける「FIFAワールドカップ2026」の日替わりロゴ(Google Doodle)、とても素敵だと思いました。
最近、TikTokやYouTubeのショート動画などでも、キャラクターの動きの軌跡(残像)が先にあって、そこに本人がピタッとはまっていくようなエフェクトをよく見かけませんか?
あの「未来のポーズに吸い込まれていく演出」、視覚的にすごく面白いですね。
これはJavaScriptで再現できるのでは?と思ったので、さっそく簡単なコードを作って実験してみました。
本来なら、残像ごとに異なるポーズを割り当てるのが理想ですが、今回は仕組みをシンプルに理解できるよう、同じ画像を使って作成しています。
それでも、不透明度のコントロールを組み合わせることで、あの独特の「雰囲気」は、まずまず再現できたのではないかと思っています。
以下に、デモと具体的なコードを示します。
画像切り替えデモ
コード
<div style="text-align: center; margin: 0; padding: 20px;">
<canvas id="myCanvas" width="600" height="200" style="display: block; margin: 20px auto;"></canvas>
<div class=" btn-container" id="buttonContainer" style="margin-top: 15px;"></div>
</div>
<script>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const totalFrames = 180;
let currentFrame = 0;
// --- 1. 画像データのセット ---
const imagePresets = [
{
name: 'パターン 1',
main: 'xxx.png', // メインの画像
target: 'xxx.png' // 残像の画像
},
{
name: 'パターン 2',
main: 'xxx.png',
target: 'xxx.png'
}
];
const imgMain = new Image();
const imgTarget = new Image();
let isImagesLoaded = false;
// --- 2. 画像セットを切り替える関数 ---
function changeImageSet(index, buttonElement) {
isImagesLoaded = false;
imgMain.src = imagePresets[index].main;
imgTarget.src = imagePresets[index].target;
// ボタンの「アクティブ(選択中)」表示を切り替える
// インラインスタイルをJavaScriptから直接書き換える
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
btn.style.background = '#fff';
btn.style.color = '#000';
btn.style.borderColor = '#ccc';
});
if (buttonElement) {
buttonElement.style.background = '#2c3e50';
buttonElement.style.color = '#fff';
buttonElement.style.borderColor = '#2c3e50';
}
let loadedCount = 0;
const checkLoad = () => {
loadedCount++;
if (loadedCount === 2) {
isImagesLoaded = true;
currentFrame = 0;
}
};
imgMain.onload = checkLoad;
imgTarget.onload = checkLoad;
}
// --- 3. ボタンの自動生成 ---
const btnContainer = document.getElementById('buttonContainer');
imagePresets.forEach((preset, index) => {
const btn = document.createElement('button');
btn.textContent = preset.name;
// ボタンの基本スタイルをインラインで設定
btn.style.padding = '10px 20px';
btn.style.margin = '0 5px';
btn.style.fontSize = '14px';
btn.style.cursor = 'pointer';
btn.style.border = '1px solid #ccc';
btn.style.background = '#fff';
btn.style.borderRadius = '4px';
btn.style.transition = '0.2s';
// マウスを乗せたとき(hover)の簡易再現
btn.addEventListener('mouseenter', () => {
if (!btn.style.background.includes('rgb(44, 62, 80)')) { // アクティブ色でなければ
btn.style.background = '#e0e0e0';
}
});
btn.addEventListener('mouseleave', () => {
if (!btn.style.background.includes('rgb(44, 62, 80)')) {
btn.style.background = '#fff';
}
});
btn.addEventListener('click', () => changeImageSet(index, btn));
btnContainer.appendChild(btn);
if (index === 0) changeImageSet(0, btn);
});
// --- 4. 軌跡・ターゲットデータの定義 ---
function getPositionAtFrame(frame) {
const progress = frame / totalFrames;
const x = 50 + progress * 500;
const y = 100 + Math.sin(progress * Math.PI * 2) * 40;
const angle = progress * Math.PI * 2;
return { x, y, angle };
}
const targets = [
{ triggerFrame: 30, alpha: 1.0 },
{ triggerFrame: 60, alpha: 1.0 },
{ triggerFrame: 90, alpha: 1.0 },
{ triggerFrame: 120, alpha: 1.0 },
{ triggerFrame: 150, alpha: 1.0 }
];
// 画像のサイズ
const imgWidth = 120;
const imgHeight = 120;
// --- 5. メインの描画ループ ---
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (isImagesLoaded) {
targets.forEach(target => {
if (currentFrame >= target.triggerFrame) {
target.alpha = Math.max(0, target.alpha - 0.02);
} else {
if (currentFrame === 0) target.alpha = 1.0;
}
const pos = getPositionAtFrame(target.triggerFrame);
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(pos.angle);
ctx.globalAlpha = target.alpha;
ctx.drawImage(imgTarget, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight);
ctx.restore();
});
const currentPos = getPositionAtFrame(currentFrame);
ctx.save();
ctx.translate(currentPos.x, currentPos.y);
ctx.rotate(currentPos.angle);
ctx.globalAlpha = 1.0;
ctx.drawImage(imgMain, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight);
ctx.restore();
currentFrame = (currentFrame + 1) % totalFrames;
}
requestAnimationFrame(draw);
}
draw();
</script>
補足
・getPositionAtFrame() という関数を作ることで、「〇フレーム目のときはこの座標にいる」という未来の予定をいつでも呼び出せるようにしました。
・あらかじめ配置した残像の画像(ターゲット)には、それぞれ「何フレーム目に踏まれるか(triggerFrame)」という情報を持たせています。
毎フレームの描画ループの中で、「現在のフレーム(currentFrame)は、ターゲットの予定フレームを過ぎたか?」を常にチェックしています。
・境界線を越えた瞬間にスイッチが入り、不透明度(alpha)が毎フレーム少しずつ減算されます。
Canvasの ctx.globalAlpha にこの数値を連動させることで、画像がジワッと消えていく滑らかな視覚効果を生み出しています。
・テスト画像の種類を増やしたい場合、imagePresets 配列に新しい画像データを追加するだけで、ボタンの生成から切り替えまでがすべて自動で完結します。
・CSSはインライン化しました。JavaScript側から btn.style.background = '#2c3e50' のように直接数値を書き換えるだけでよいです。
・インラインスタイルではCSSの :hover(マウスが乗ったとき)が使えないため、JavaScriptの mouseenter(マウスが入ったとき)と mouseleave(離れたとき)のイベントを使って、ボタンが少し暗くなる演出をしています。