多くのテーマについているアイコンのついた吹き出し機能
このアイコンの表情がアニメみたいに動いたらすごいのになあ……なんて思ったことありませんか?
私は思いました。
そこで、

実際に動かしてみました
吹き出しアイコンが1回だけウィンクするデモ
最初に、動作イメージのデモです。
デモ枠の中をスクロール すると、吹き出しキャラが見えてきたタイミングで 1回だけウィンク します。
※埋め込みのバーの中の、さらにその中のバーをスクロールさせてください。
※「もう一度試す」を押すと、先頭に戻って再確認できます。
See the Pen
スクロールで吹き出しキャラがウィンクするデモ by 天満川鈴 (@kimoota)
on CodePen.
実装の考え方
吹き出しアイコンをアニメで動かす仕組み
私が考えたのは、吹き出し全体を動かすのではなく、顔の部分だけを軽く変化させることです。
何を扱うサイトか、どういうペルソナか、どういう目的で運営するかによって、採用する演出は異なります。
その引き出しの一つになれば、というのが今回の実務的な狙いでした。

さっくり言うと、今回の仕組みは次の3つです
吹き出しアイコンをアニメで動かす仕組み:
・CSSスプライト画像で表情差分を持つ
・@keyframes で切り替える
・Intersection Observer で発火する
CSSスプライト画像で表情差分を持つ
今回用いた技術はCSSスプライト。
複数の画像を繋いで1枚とし、背景(background)に設定します。
background-position を切り替えることでアニメーションのように見せることができます。
こちらが今回の画像です。
左から通常顔・途中の表情・ウィンクの差分を並べて、1枚のアイコン画像にしています。

注意点としては、1枚あたりの画像の幅をぴったり揃えること。
また、こうした表情変化なら3枚くらいでも十分アニメに見えます。
これ以上増やすと、かえって動きが不自然になることもあります。
@keyframes で切り替える
表情の切り替えは、@keyframesを使って行います。
今回はウィンクさせたい吹き出しキャラにkm-is-winkingクラスを付与し、1回だけアニメーションするようにしました。
.km-speech-person.km-balloon-icon-wink.km-is-winking{
animation: km-wink-once 0.28s steps(1) 1;
}
steps(1) を使っているのがポイントです。
これを入れることで、ぬるっと補間せず、コマ送りのようにパッと切り替わります。
今回のようなCSSスプライト画像とは相性のいい書き方です。
時間は0.28sを指定しています。
このくらいが、自然な動きを演出するのにちょうどいい塩梅です。
@keyframesで、背景画像の位置を順番にずらしていきます。
@keyframes wink-once {
0% { background-position: 0 0; }
33% { background-position: -150px 0; }
66% { background-position: -300px 0; }
100% { background-position: 0 0; }
}
横幅150pxの画像を3枚並べているので、150px単位で切り替えます。
最後に0を指定しているのは元の表情へ戻すため。
これによって1回だけ軽く反応したように見せられます。
Intersection Observer で発火させる
Intersection Observerは、ある要素がブラウザでどのくらい見えているかを監視する機能です。
これを用いてウィンクのタイミングを制御しています。
今回はデモですので、ページ全体ではなくデモ枠の中をスクロールした時に発火するようにしました。
コードを見る際は、その点に御注意ください。
kmObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target;
if (el.dataset.kmWinkPlayed === 'true') return;
el.dataset.kmWinkPlayed = 'true';
el.classList.add('km-is-winking');
el.addEventListener('animationend', function handler() {
el.classList.remove('km-is-winking');
el.removeEventListener('animationend', handler);
});
kmObserver.unobserve(el);
});
}, {
root: kmDemoScroll,
rootMargin: '0px 0px -8% 0px',
threshold: 0.85
});
発火タイミングはthreshold: 0.85の数字で調整できます。

お好みにあわせて調整を……と言いたいんですけど
実は、ここの設定に今回の方法の難しさがあります(後述)。
今回のデモでは動作を繰り返さないよう、1回だけに制限しています。
if (el.dataset.winkPlayed === 'true') return;
何度も動作を繰り返すのは鬱陶しいと考えたためです。
しかしこのフラグの取扱についても、正解は時と場面で異なってくるものと思われます。
コード一式
今回のデモで使ったコード一式です。
デモ用ですので、そのままコピペしても意図通りには動きません。
サイトに実装する際は以下の点などに御注意ください。
- 実際の吹き出しクラスに合わせる
- デモ枠ではなく記事全体のスクロール監視に変える
【HTML】
<div class="km-demo-wrap"> <div class="km-demo-header"> <h2>スクロールで吹き出しキャラがウィンクするデモ</h2> <button id="kmResetDemoBtn" type="button">もう一度試す</button> </div> <div class="km-demo-scroll" id="kmDemoScroll"> <div class="km-demo-content"> <p>このデモは、スクロールで吹き出しキャラの表情を変えるサンプルです。</p> <p>少し下までスクロールすると、まとめ役のキャラが1回だけウィンクします。</p> <p>CSSスプライト画像を使って、background-position を切り替えています。</p> <p>Intersection Observer を使って、表示領域に入ったタイミングで発火させています。</p> <p>記事本文全体ではなく、この枠の中だけで動作するようにしてあります。</p> <div class="km-demo-spacer"></div> <div class="km-speech-box"> <div class="km-speech-person km-balloon-icon-wink" id="kmWinkTarget"></div> <div class="km-speech-text"> ここがまとめです。<br> スクロールで見えてきたタイミングで、1回だけウィンクします。 </div> </div> <div class="km-demo-bottom-space"></div> </div> </div> </div>
【CSS】
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f7f7f9;
color: #333;
}
.km-demo-wrap {
max-width: 760px;
margin: 0 auto;
}
.km-demo-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.km-demo-header h2 {
margin: 0;
font-size: 20px;
line-height: 1.4;
}
#kmResetDemoBtn {
appearance: none;
border: 0;
background: #222;
color: #fff;
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
#kmResetDemoBtn:hover {
background: #444;
}
.km-demo-scroll {
height: 420px;
overflow-y: auto;
border: 1px solid #ddd;
background: #fff;
border-radius: 14px;
padding: 24px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.km-demo-content p {
margin: 0 0 16px;
line-height: 1.9;
}
.km-demo-spacer {
height: 220px;
}
.km-demo-bottom-space {
height: 180px;
}
.km-speech-box {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 18px;
background: #f3f8ff;
border: 1px solid #d7e7ff;
border-radius: 16px;
}
.km-speech-person.km-balloon-icon-wink {
flex: 0 0 150px;
width: 150px;
height: 150px;
background-image: url('ウィンクさせたい画像のURL');
background-size: 450px 150px;
background-repeat: no-repeat;
background-position: 0 0;
border-radius: 12px;
background-color: #fff;
}
.km-speech-person.km-balloon-icon-wink.km-is-winking {
animation: km-wink-once 0.28s steps(1) 1;
}
@keyframes km-wink-once {
0% { background-position: 0 0; }
33% { background-position: -150px 0; }
66% { background-position: -300px 0; }
100% { background-position: 0 0; }
}
.km-speech-text {
flex: 1;
min-width: 0;
font-size: 16px;
line-height: 1.9;
padding-top: 8px;
}
【JS】
document.addEventListener('DOMContentLoaded', function () {
const kmDemoScroll = document.getElementById('kmDemoScroll');
const kmWinkTarget = document.getElementById('kmWinkTarget');
const kmResetDemoBtn = document.getElementById('kmResetDemoBtn');
if (!kmDemoScroll || !kmWinkTarget || !kmResetDemoBtn) return;
let kmObserver;
function kmSetupObserver() {
kmObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target;
if (el.dataset.kmWinkPlayed === 'true') return;
el.dataset.kmWinkPlayed = 'true';
el.classList.add('km-is-winking');
el.addEventListener('animationend', function handler() {
el.classList.remove('km-is-winking');
el.removeEventListener('animationend', handler);
});
kmObserver.unobserve(el);
});
}, {
root: kmDemoScroll,
rootMargin: '0px 0px -8% 0px',
threshold: 0.85
});
kmObserver.observe(kmWinkTarget);
}
function kmResetDemo() {
if (kmObserver) kmObserver.disconnect();
kmWinkTarget.dataset.kmWinkPlayed = 'false';
kmWinkTarget.classList.remove('km-is-winking');
// アニメ状態を確実にリセット
void kmWinkTarget.offsetWidth;
kmDemoScroll.scrollTo({
top: 0,
behavior: 'smooth'
});
setTimeout(() => {
kmSetupObserver();
}, 150);
}
kmSetupObserver();
kmResetDemoBtn.addEventListener('click', kmResetDemo);
});
実装できても、使いどころはちょっと難しい
使い所が難しい3つの視点
やってみた感想は、

面白い!
単純に吹き出しが動くだけでも面白い。
しかも今回の吹き出しアイコンはChatGPTに作ってもらったもの。
美麗で高精細なイラストが動くのですから、色んな意味で「アニメ」を実装した感覚があります。
でも、

実際にサイトに組みこむとなると、使いどころが難しいかも……
私としては、以下の3つの視点からそう思います。
- 乱用するとノイズになる
- 効果が強烈ゆえノイズになる
- 発火タイミングが難しい
乱用するとノイズになる
まず、ここはマーケティングにおける一つの作法です。
印象を与えるための演出は、他を大人しくして絞り込んで使うからこそ引き立ちます。
どんな演出であれ使いすぎると効果が薄れます。
特に演出の効果が強い場合には、引き立たせるどころかノイズになります。
例を出しましょう。
白いキャンバスの上に黒い点が1つだけポツンとある場合、その点と周辺には注目が集まります。
しかし点がいくつもある場合はどこを見たらいいかわからなくなります。
さらにキャンバスが真っ黒になると、もはや演出があることすらわからなくなります。
この観点から、今回のデモではウィンクを1回にとどめています。
サブタイトルの「……今、ウィンクした?」くらいのさりげなさで丁度いいです。
さらに、具体的にはブログ記事のまとめセクション限定で使う意図で作成しました。
ただし例外もあります。

キャンバスが真っ黒であること自体がサイト全体の演出として成立するときね
具体的にはエンターテイメント系のコンテンツ。
ゲームだったり、パチンコだったり。
こうした製品サイトなら全体が賑やかなこと自体がアピールとして成立しますからアリと思います。
効果が強烈ゆえノイズになる
この演出そのものは、冒頭に述べた通り、

面白い!
です。
しかし、だからこそ使いどころが難しくなります。
私が考えたのは、まとめセクションの最後の一文の吹き出しでの使用でした。
締めにインパクトを与えることで読後感が増すかなあと。
しかし実際に置いて試してみると、必ずしもそうと言えないように思えました。

「面白い」だけじゃない、「面白すぎる」んだよ
演出に気づいてしまうと、テキストよりアイコンに目がいってしまう可能性があります。
テキストを読み終えた後なら計算通りですけど。
読んでる途中だと、せっかくの決めゼリフの印象がぼやけてしまいます。
もともと吹き出しは本文の補助的なもの。
アイコンだけでその機能は十分に果たすのであって、積み増したからといってUX改善するとは考えない方がいい。
雰囲気をさらに軽くしたいときや愛嬌を添えたい。
そんな感じで用いるのがよさそうです。
CTA誘導で用いることに対する私見と注意
そうなると、相性のいいケースとして真っ先に思い浮かぶのは恐らくCTA誘導でしょう。
事実、AI執事のGemini君、ChatGPT君、RakutenAI君は3人とも口を揃えました。

この演出はどう使えば効果あるかなあ?

CTA誘導

CTA誘導

CTA誘導

この無能! 貴様ら、もっと頭を使え!
どういうことか。
CTAの基本は「迷わせない」ことですが、これを阻害するおそれがあります。
2つの視点から見てみましょう。
1つは、先述の「面白すぎる」ことに起因します。
吹き出しそのものには重いテキストを軽く読ませる効果があります。
言い換えると、CTAを押させるための心理的ハードルを下げる効果があります。
しかしアイコンがウィンクしてしまうと、

……今、うごいた!?

すごい!

どうやってるの!?
どんなリアクションであれ、演出の方に気をとられてしまう可能性大。
せっかく得られるかもしれなかった動機は明後日の方向へ消え去ってしまうかもしれません。
1つは、あざといこと。
吹き出し機能は最近の国産テーマですと当たり前に備わっており、実装に手間はかかりません。
受け手の側にしても広まっている演出であることから抵抗なく読み進めます。
しかしアニメとなるとどうか。
少なくとも、単なる吹き出しと比較して簡単に実装しているようには思えません。
なので、やりすぎ感が生まれるんです。
CTAは、書き手が利益を得るための誘引ツールとして設置します。
どんな形であれ書き手に下心はありますし、そこは当然。
読み手だって薄々は察しながら読みます。
それでも警戒より好奇心や欲望が優ればCTAを押してくれます。
この「薄々は察しながら」というのが問題でして。
アイコンがウィンクまでしてしまうと、

CTA押させるためにそこまでやるか!
山っ気が前面に出てしまうことで、読み手の心境が素に引き戻されるおそれがあります。
「かわいい」を通り越して、もはや「あざとい」んです。
大手企業のLPみたいに全体の演出設計まで揃っていれば成立するかもしれませんが、普通のブログやメディアだとノイズのほうが勝つ気がします。
もちろん、前項の演出に気をとられてしまう問題もあります。
例えアニメ演出に好感を抱いてもらったとしても、CTAを押してもらえないなら用を為しません。
ただCTAの種類にもよるでしょう。
例えば求人。

応募する私のために、ここまで作り込んでくれるんだ……
プラスの可能性に向くことも考えられます。
「求人におけるCTAは重要」と言われますが、実際にここまでやってる会社は多くないはず。
一考してみる価値はありそうに思います。
発火タイミングが難しい
最後に技術面からの視点です。
吹き出しアイコンのアニメを実装する自体は難しくありません。
しかしどうセッティングするかは困難を極めます。
この手の演出は、決めたいタイミングでバシッと決めてこそ最大の効果を発揮します。
でも、

どこで発火すればいいの?
読了時? スクロール時? 見出し到達時?
スクロールだとしたらどこまで進んだところ?
文章を読む速度は人によって異なりますし、記事の内容によっても異なります。
よしんばそれらがある程度読めたとしても。
デバイスがモバイルかPCかによっても適切なタイミングは異なってきます。
実際に実装してテストしてみてください。
その難しさがわかると思います。
もっとも解決策がないわけでもありません。
私としては、次のとおり考えます。

ダミーセクションを置いて発火させるのが一番実用的じゃないかな?
狙った要素の至近距離で発火させることが可能となりますから、調整幅が少なくて済みます。
まとめ
吹き出しアイコンにウィンクさせる演出はとても面白い。
だからこそ紹介させていただきました。
しかし面白すぎるがゆえに毒もあります。
動作は控えめに、使う箇所は絞って、なおかつ使いどころを見極める必要があります。
ですが……

それでもやっぱり面白い!
1周してしまいましたが、結局は使い方じゃないでしょうか?
例えばウィンクでなく、
- 目パチ
- 口の開閉
- 驚き顔への切り替え
- まとめだけ笑顔にする
動きは色々応用できます。
動きと目的の組合せによって効果を発揮する場面は増えそうです。
もしかしたら目的そのものにも、もっと効果を生みやすいものがあるかもしれません。
うまく使えば大きな効果を発揮する演出なのは間違いない。
私も適切な場面に出会ったときは使ってみようと思っています。

もし「こんな風に使うといいかもよ?」なんてあったら教えてくださいね!
私はConoHa以外を勧めない。
2016年からずっとConoHaを使い倒してきました。知人に「一番いいサーバーは?」と聞かれたら、迷わずここを教えます。
レンタルサーバーナンバーワンを誇る高速環境であることはもちろん。私が「黒い画面って何?」というド素人からサイト制作のプロになれたのは、傍らにずっとこのはちゃんがいてくれたから。
私がConoHaを使い続ける、嘘偽りない理由です。
※ConoHaに初めて入会の方限定。
本CTAの画像もしくはボタンを押してWINGパック12か月以上を契約すると、最大5000円割引してもらえます。