「自然な動き」を作ろうとすると、意外な難しさを感じることが多いのではないでしょうか?
ただ動かすだけなら簡単なのに、
生き物っぽくしようとすると一気に出てくる、「なんか違う感」。
とりあえずバウンス系のイージングを試してみるけど、
なんか違うまま終わる、あの感じ。
この記事では、GSAPを使って
「生きているように見える蝶のアニメーション」を実装しながら、
上記3つのポイントを説明していこうと思います。
ということで、まずは 完成品 から
See the Pen butterfly by maehata (@maehata1) on CodePen.
基本構造
まずは、今回作る蝶の基本の構造を作成していきます。
使用画像
HTML
<!-- gsapの読み込み -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<div class="butterfly">
<div class="butterfly_body" data-butterfly-body>
<img class="butterfly_wing butterfly_wing__left" src="./butterfly_left.png" alt="左羽の画像" width="200" height="400">
<img class="butterfly_wing butterfly_wing__right" src="./butterfly_right.png" alt="右羽の画像" width="200" height="400">
</div>
</div>
構造としては、蝶々を入れる箱の中に蝶を配置しています。
羽をrotateで羽ばたかせるため、蝶の画像を真っ二つにしています。
また、[data-butterfly-body]でJSに要素を渡します。
今回は、アニメーションの作成にGSAPを利用するため、こちらも読み込んでおきましょう。
CSS
/* css */
.butterfly {
display: block;
position: absolute;
width: 150px;
height: 150px;
top: 50%;
left: 50%;
translate: -50% -50%;
transform-origin: center;
pointer-events: none;
}
.butterfly_body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
}
.butterfly_wing {
width: 100%;
height: 100%;
}
.butterfly_wing__left {
/* 左羽の中心を、右中央に設定 */
transform-origin: right center;
}
.butterfly_wing__right {
/* 右羽の中心を、左中央に設定 */
transform-origin: left center;
}
2つの羽のrotateを体の中心にしたいので、各羽ごとにtransform-originを設定しています。
JS
// 蝶の羽ばたきのアニメーション
const animateWingFlap = (target) => {
const [leftWing, rightWing] = target.querySelectorAll('img');
if (!leftWing || !rightWing) return;
const flapAngle = 60;
const flapDuration = 1;
const loop = () => {
gsap.timeline({
onComplete: loop //アニメーションtimelineの終了でループを発火
})
// 羽を閉じるアニメーション
.to([leftWing, rightWing], {
rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),
duration: flapDuration * 0.5,
})
// 羽を開くアニメーション
.to([leftWing, rightWing], {
rotationY: 0,
duration: flapDuration * 0.5,
});
};
loop();
};
const butterflyAnimation = () => {
const butterflyList = document.querySelectorAll('[data-butterfly-body]');
if (!butterflyList.length) return;
butterflyList.forEach((el) => {
animateWingFlap(el);
});
};
butterflyAnimation();
animateWingFlap()は 以下構成でできています。
- 蝶の羽の開閉度を
flapAngleで、羽ばたきの時間をflapDurationで定義する - gsapで羽を広げるアニメーションと、羽を閉じるアニメーションを追加
-
timelineのonCompleteでgsapのアニメーション設定をループする
現状では、機能はしているけどどこか機械的です。
なので、ここから3つのポイントを活かして、蝶を生き物らしくしていきます。
Point1. easeによる緩急の表現
ここからはeaseをつけて、アニメーションを生き物らしく情緒的にしていきます。
ですが、その前にeaseとは何のためにあるのでしょうか?
まずはeaseの意味から考えていきましょう。
■ easeは「緩急」を作るもの
生き物の動きが自然に見える理由の一つに、緩急/速度の変化があります。
これは、筋肉の動きとある程度対応しています。
- 力を入れるとき(収縮) → 素早く動く
- 力が抜けるとき(弛緩) → ゆるやかに動く
※実際の筋肉はもっと複雑ですが、ここでは見た目のイメージとして捉えています。
■ 蝶の羽ばたきに当てはめる
この考え方を蝶の羽ばたきに当てはめると、次のようになります。
- 羽を閉じる → 素早く動き出して減速する
- 羽を開く → なめらかに動く
■ easeの場合
この「緩急」をeaseで表現するとこうなります。
// 閉じる(急 → 緩)
duration: 0.4,
ease: "power2.out"
// 開く(なめらか)
duration: 0.6,
ease: "sine.in"
■ ポイント
急な動き → 強めなease-out系
緩やかな動き → 緩めなease (in/out/inOut は場面に合わせて使い分ける)
このように速度カーブを設計することで、自然な動きに見せることができます。
上記をanimateWingFlap()に追加したコードは以下になります。
const animateWingFlap = (target) => {
const [leftWing, rightWing] = target.querySelectorAll('img');
if (!leftWing || !rightWing) return;
const flapAngle = 60;
const flapDuration = 1;
const loop = () => {
gsap.timeline({
onComplete: loop
})
.to([leftWing, rightWing], {
rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),
// durationとeaseの変更を追加
duration: flapDuration * 0.4, // 短め
ease: "power2.out" // 急を意識したeasing
})
.to([leftWing, rightWing], {
rotationY: 0,
// durationとeaseの変更を追加
duration: flapDuration * 0.6, // 長め
ease: "sine.in" // 緩を意識たeasing
});
};
loop();
};
グッと躍動感が上がりました。
Point2. ランダムによる揺らぎの追加
現状のアニメーションは、一定の動きを繰り返しているだけなので単調に見えます。
そこでランダム値を使って、動きに揺らぎを加えます。
ただし、単純に Math.random() を使うだけでは問題があります。
value = Math.random() * 100;
このようにすると、値の制御ができないため
- ほとんど動かない
- 動きすぎる
- 羽が不自然にクロスする
等の意図しない挙動が発生します
そこで、ランダムの範囲を制御します。
const randomBetween = (min, max) => (Math.random() * (max - min)) + min;
これにより
- 最小値〜最大値の範囲内でランダムになる
- 動きの破綻を防げる
- チューニングがしやすくなる
完全なランダムではなく、
意図した範囲の中で揺らすことが重要です。
■ 羽の動きにランダムを適用する
では、先ほど作成したランダム関数を、羽ばたきの「時間」と「角度」に適用してみます。
ループごとにランタム値を適用したいので、flapAngleとflapDurationはloop内で定義します。
// 最小値と最大値を入れた配列の追加
const WING_FLAP_DURATION_RANGE = [0.4, 1.5]; // 時間の最小と最大値
const WING_FLAP_ANGLE_RANGE = [50, 80]; // 角度の最小と最大値
// ランダムな値を生成する関数の追加
const randomBetween = (min, max) => (Math.random() * (max - min)) + min;
const animateWingFlap = (target) => {
const [leftWing, rightWing] = target.querySelectorAll('img');
if (!leftWing || !rightWing) return;
const loop = () => {
// ランダムな角度を取得
const flapAngle = randomBetween(...WING_FLAP_ANGLE_RANGE);
// ランダムな時間を取得
const flapDuration = randomBetween(...WING_FLAP_DURATION_RANGE);
gsap.timeline({
onComplete: loop
})
// ランダム値を適用
.to([leftWing, rightWing], {
rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),
duration: flapDuration * 0.4,
ease: 'power2.out',
})
.to([leftWing, rightWing], {
rotationY: 0,
duration: flapDuration * 0.6,
ease: 'sine.in',
});
};
loop();
};
ランダムを加えることで、羽ばたきのリズムに揺らぎが生まれます。
■ 蝶全体の動きにもランダムを適用
さらに、蝶の位置・向きにもランダムを加えます。
const WING_FLAP_DURATION_RANGE = [0.4, 1.5];
const WING_FLAP_ANGLE_RANGE = [50, 80];
// 蝶の位置・向き・時間の最大と最小値
const WING_X_RANGE = [-40, 40];
const WING_Y_RANGE = [-40, 40];
const WING_ROTATION_RANGE = [-40, 40];
const WING_DURATION_RANGE = [1.2, 2.8];
const randomBetween = (min, max) => (Math.random() * (max - min)) + min;
const animateWingFlap = (target) => {
// 省略
}
// 蝶の位置・向きをランダムにアニメーションさせる関数の追加
const animateButterflyTransform = (target) => {
const loop = () => {
gsap.to(target, {
x: randomBetween(...WING_X_RANGE),
y: randomBetween(...WING_Y_RANGE),
rotation: randomBetween(...WING_ROTATION_RANGE),
duration: randomBetween(...WING_DURATION_RANGE),
ease: 'sine.inOut',
onComplete: loop,
});
};
loop();
};
const butterflyAnimation = () => {
const butterflyList = document.querySelectorAll('[data-butterfly-body]');
if (!butterflyList.length) return;
butterflyList.forEach((el) => {
animateWingFlap(el);
//蝶の位置・向きをアニメーションさせる関数の呼び出し
animateButterflyTransform(el);
});
};
butterflyAnimation();
ランダムな移動が加わることで、空間内を漂うような動きになります。
規則性を少しだけ崩すことで、一気に生き物らしさが生まれました。
Point3. scaleとshadowによる奥行きの演出
ここまでで動きは自然になりましたが、まだ少し平面的に見えます。
そこで、奥行き・立体感を追加します。
■ 説明
奥行きは「カメラ・物体・地面」の関係で考えると整理しやすくなります。
これら三つの関係性は、上下の1軸でこのようになります。
カメラ(視点)| 物体(蝶)| 地面
このとき、それぞれの役割は以下の通りです。
- scale → カメラとの距離(大きさ)
- shadow → 地面との距離(高さ)
現実ではこの2つは連動しています。
- 物体が上にいく(カメラに近づく) → 大きく見える + 影が薄い
- 物体が下にいく(カメラから離れる) → 小さく見える + 影が濃い
■ 実装
上記の説明を元に実装を行っていきます。
まず、scaleとshadowを連動させていきます。
const WING_SCALE_RANGE = [0.9, 1.1]; //スケールのランダム範囲
// スケールを元に影の強さを正規化
const getShadowFactor = (scale) => {
const [minScale, maxScale] = WING_SCALE_RANGE;
return (scale - minScale) / (maxScale - minScale);
};
getShadowFactor()では、ランダムで取得したscaleの値を
0〜1の範囲に正規化しています。
これにより
- scaleの値に依存せず
- 一貫した基準で影の強さを扱える
→ 他の値(影)と連動させやすくなります
これらを、位置や回転をアニメーションさせていた、animateButterflyTransform()内で呼び出します。
const WING_FLAP_DURATION_RANGE = [0.4, 1.5];
const WING_FLAP_ANGLE_RANGE = [50, 80];
const WING_X_RANGE = [-40, 40];
const WING_Y_RANGE = [-40, 40];
const WING_ROTATION_RANGE = [-40, 40];
const WING_DURATION_RANGE = [1.2, 2.8];
// 蝶のスケールの最小と最大値を追加
const WING_SCALE_RANGE = [0.9, 1.1];
const randomBetween = (min, max) => (Math.random() * (max - min)) + min;
// スケールを元に影の強さを正規化する関数の追加
const getShadowFactor = (scale) => {
const [minScale, maxScale] = WING_SCALE_RANGE;
return (scale - minScale) / (maxScale - minScale);
};
const animateWingFlap = (target) => {
// 省略
}
const animateButterflyTransform = (target) => {
const loop = () => {
//スケールをランダムで取得し、影の強さを定義する
const scale = randomBetween(...WING_SCALE_RANGE);
const shadowFactor = getShadowFactor(scale);
gsap.to(target, {
// 省略
// gsap内でscaleとcss関数のアニメーションを適用
scale,
'--butterfly-shadow-factor': shadowFactor,
});
};
loop();
};
const butterflyAnimation = () => {
// 省略
};
butterflyAnimation();
drop-shadowの値はJSで書くと冗長になるため、
CSS変数を使い管理します。
.butterfly_body {
/* 影の強さを取得する変数の追加 */
--butterfly-shadow-factor: 0;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
/* 影の追加 */
filter: drop-shadow(
calc(6px + (8px * var(--butterfly-shadow-factor)))
calc(6px + (8px * var(--butterfly-shadow-factor)))
calc(6px + (10px * var(--butterfly-shadow-factor)))
rgba(0, 0, 0, 0.6)
);
}
最後に--butterfly-shadow-factorをCSSで影として利用できる形に変換します。
calc(6px + (8px * var(--butterfly-shadow-factor)))
このcalcを使った計算は、
- 最低値 → 6px(ベースの影)
- 追加分 → 最大で +8px(ぼかし・距離)
→ 0〜1の値を、影の変化量に変換しています
ということで、これらを反映させたものが以下になります。

自然な奥行き感が出て、クオリティがグッとあがりました!!
まとめ
今回の実装では、蝶のアニメーションをより自然に見せるために、いくつかの工夫を行いました。
どれも特別なことではありませんが、これらを組み合わせることで、単調だった動きに 生き物らしさ を与えることができます。
最後に
ここまで実装での工夫について説明してきましたが、
一番重要なことは、現実の生き物や環境を観察することです。
- なぜこの動きは自然に見えるのか
- なぜ違和感が出るのか
その感覚をもとに、
現実の動きを少しずつ分解して、コードに置き換えていく
こういう試行錯誤が、表現の面白さだと思います。





