GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css キャラクター アニメーション 9選 — 挨拶から居眠りまで

css キャラクター アニメーションは、画面に立つマスコットの体・腕・目を transform だけで動かし、待っている・失敗した・終わったという状態を絵のほうで伝える仕組みです。ここに並べた9つはライブラリもウェブフォントも使わず、index.html と style.scss の2枚だけで動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの並びは人気順ではなく、キャラクターが1つの画面の中でたどる時間の順です。最初の3つは人がまだ何もしていない段階で、じっと立って呼吸し(01)、下から現れて手を振り(02)、次に押す場所を腕で指します(03)。続く3つは人が触っている最中で、パスワードを打つ間だけ目を覆い(04)、待たされる数秒を箱運びで埋め(05)、値が違えば首を振ります(06)。最後の3つは結果と、その後に残る時間です。跳び上がって一度だけ祝い(07)、なでられて沈み(08)、誰も触らなくなると眠ります(09)。イラスト アニメーション 作り方は絵の側から語られることが多いのですが、ここでは体の比率と目鼻の座標を9つ全部で共有し、変えるのは transform と舞台の色だけにしてあります。舞台の色は紙3・黄3・橙2・墨1 の配分で振り分けました。案内の吹き出しや進捗の点まで含めた画面全体の組み方はチュートリアル UI デザイン9選にまとめてあります。同じinteraction柱の両隣には、キャラクターではなく指が画面を引っ張るタッチパネル 注文 画面のUI 9選と、キャラクターを立たせる背景をコードで描き起こすcss 背景 パターン ジェネレーター 自作9種が並んでいます。

01呼吸する待機ポーズ

誰も触れていない間、体がゆっくり膨らんでは沈み、その下の影は逆に狭くなったり広がったりして、目は一定の間隔で1フレームだけ閉じます。

呼吸する待機ポーズ
新しいタブで開く

01 · 呼吸する待機ポーズ — 計測 累積面積 2.49% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scaleYsteps(1, end)transform-originanimation-iteration-count
@keyframes mbBreath {
  0%, 100% { transform: scaleY(1) scaleX(1); }
  50%      { transform: scaleY(1.085) scaleX(.975); }
}
@keyframes mbShadow {
  0%, 100% { transform: scaleX(1); opacity: .18; }
  50%      { transform: scaleX(.84); opacity: .12; }
}
@keyframes mbBlink {
  0%, 74%   { transform: scaleY(1); }
  76%, 80%  { transform: scaleY(.08); }
  82%, 100% { transform: scaleY(1); }
}
.mb.is-demo .mb__eye {
  transform-origin: 50% 50%;
  animation-name: mbBlink;
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}

原点を足元に置く理由

膨らむ向きが「上」ではなく「足を床に着けたまま上」なので transform-origin は中心ではなく底(50% 100%)に置いてあり、影は床に押しつぶされた面なので縦には伸びず scaleX だけを逆位相で動かします。

まばたきをコマで切る場所

まぶたは 76% と 80% の2点でだけ閉じ、steps(1, end) が中間フレームを作らないので半開きの幽霊のような顔が残らず、この面は累積面積 2.49% と小さいのに動いたフレームは 23/23 です。

02手を振る挨拶

下から現れたキャラクターが腕を左右に振り、腕が外へ開くときには体が反対側へ傾きます。

手を振る挨拶
新しいタブで開く

02 · 手を振る挨拶 — 計測 累積面積 5.653% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatetransform-originanimation-fill-modetranslateY
.wg__arm {
  position: absolute; left: 107px; top: 32px; width: 12px; height: 44px;
  border-radius: $r-pill; background: $color;
  transform-origin: 50% 95%;
  transform: rotate(24deg);
}
.wg.is-demo .wg__arm {
  animation-name: wgWave;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: 560ms;
  animation-fill-mode: backwards;
}
@keyframes wgRise {
  0%        { transform: translateY(36px); }
  26%, 100% { transform: translateY(0); }
}

肩を軸に腕を回す

腕は独立した部品ではなく肩に付いているので、transform-origin: 50% 95%、つまり腕の下端を軸にして rotate だけで動かし、体が同じ側へ傾くと倒れて見えるため傾きは逆位相にしてあります。

遅延に backwards を添える

腕と体は登場が終わった直後の 560ms 地点から始まるため animation-delay を入れますが、animation-fill-mode: backwards を一緒に付けないと遅延の間だけ腕が既定の姿勢で1フレームちらついて消え、登場が 0〜26%、戻りが 88% で終わるこの面の動いたフレームは 16/23 です。

03次のボタンを指す案内

腕がボタンのほうへ伸びて瞳も同じ方向へ寄り、ボタンには強調のリングが広がっては消え、案内の文言が3段階で入れ替わります。

次のボタンを指す案内
新しいタブで開く

03 · 次のボタンを指す案内 — 計測 累積面積 3.006% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatetranslateXaria-livebox-shadow
@keyframes pgPoint {
  0%, 31%   { transform: rotate(80deg); }
  34%       { transform: rotate(92deg); }
  37%, 62%  { transform: rotate(80deg); }
  65%       { transform: rotate(92deg); }
  68%, 86%  { transform: rotate(80deg); }
  88%       { transform: rotate(92deg); }
  90%, 100% { transform: rotate(80deg); }
}
@keyframes pgLook {
  0%        { transform: translateX(0); }
  8%, 100%  { transform: translateX(3px); }
}
.is-demo .pg__msg--0,
.is-demo .pg__msg--1,
.is-demo .pg__msg--2 {
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}

目の器ではなく瞳だけ動かす

目の器ごと横へずらすと顔がゆがむので、動かすのは中の瞳だけにして translateX(3px) という小さな移動にとどめてあります。

カットと補間が出会う場所

文言の入れ替えは steps(1, end) のカットなのでフレームを2枚しか作りませんが、その隣で腕が 80deg と 92deg の間を往復しているため、動いたフレームは 19/23 まで上がります。

04パスワード欄で目を隠す

パスワード欄に文字が入り始めると両腕が上がって目を覆い、表示ボタンを押すと腕が下がって目がまた現れます。

パスワード欄で目を隠す
新しいタブで開く

04 · パスワード欄で目を隠す — 計測 累積面積 2.806% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatetransform-originaria-hiddentype="password"
@keyframes mcArmL {
  0%, 24%   { transform: rotate(28deg); }
  36%       { transform: rotate(-99deg); }
  42%       { transform: rotate(-92deg); }
  48%, 56%  { transform: rotate(-99deg); }
  70%, 100% { transform: rotate(28deg); }
}
@keyframes mcArmR {
  0%, 24%   { transform: rotate(-28deg); }
  36%       { transform: rotate(99deg); }
  42%       { transform: rotate(92deg); }
  48%, 56%  { transform: rotate(99deg); }
  70%, 100% { transform: rotate(-28deg); }
}
@keyframes mcWink {
  0%, 32%   { transform: scaleY(1); }
  34%, 54%  { transform: scaleY(.1); }
  56%, 100% { transform: scaleY(1); }
}

肩の関節は腕の上端にある

腕は上へ滑るのではなく肩で回るので、transform-origin を腕の上端(5px 5px)に置き、静止の 28deg から目を覆う -99deg まで rotate だけで動かし、右腕は符号を反転した鏡像の角度で動きます。

意味を運ぶのは文字のほう

キャラクター自体は aria-hidden="true" の飾りで、状態を伝えるのは表示ボタンの aria-pressed と、押すたびに書き換わるラベルのほうです。

05荷物を運ぶローディング

箱を頭の上に載せたキャラクターが左右に往復し、その下でバーが左から埋まり、パーセントの数字が桁ごとに回ります。

荷物を運ぶローディング
新しいタブで開く

05 · 荷物を運ぶローディング — 計測 累積面積 33.276% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXscaleXanimation-directionrole="progressbar"
.ml__lane {
  position: absolute; left: 0; right: 0; bottom: 0; height: 140px;
  --char-w: 104px;
  animation-name: mlWalk;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-direction: alternate;
  animation-iteration-count: infinite;
}
@keyframes mlWalk {
  0%   { transform: translateX(calc(-1 * (100% - var(--char-w)))); }
  100% { transform: translateX(0); }
}
@keyframes mlStep {
  0%, 100% { transform: translateY(0) scaleY(.9); }
  40%      { transform: translateY(-6px) scaleY(1.05); }
  80%      { transform: translateY(0) scaleY(1); }
}

歩く道を右端に寄せる

通り道を全幅のまま右へ translateX すると動いている間だけ scrollWidth が伸びるので、通り道は右端に固定して負の方向にだけ動かしています。

周期を2秒の約数でそろえる

往復1周が2秒、歩きは animation-direction: alternate で片道1秒、足の着地は 0.25秒と、すべて2秒の約数なので、24枚のフレームがちょうど一周を捉え、この面は累積面積 33.276%・強度 192.4 と9つで最も激しく動きます。

06首を振る失敗

値が違うとキャラクターが首を左右に振り、入力欄も同じ拍で揺れ、表情は笑顔から悲しい顔へ1フレームで入れ替わります。

首を振る失敗
新しいタブで開く

06 · 首を振る失敗 — 計測 累積面積 9.047% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXsteps(1, end)aria-liveanimation-name
@keyframes seHead {
  0%, 11% { transform: translateX(0) rotate(0deg); }
  18% { transform: translateX(-10px) rotate(-7deg); }
  26% { transform: translateX(10px) rotate(7deg); }
  34% { transform: translateX(-9px) rotate(-6deg); }
  42% { transform: translateX(9px) rotate(6deg); }
  48% { transform: translateX(-7px) rotate(-5deg); }
  54% { transform: translateX(7px) rotate(5deg); }
  60% { transform: translateX(-4px) rotate(-3deg); }
  66% { transform: translateX(4px) rotate(3deg); }
  72%, 100% { transform: translateX(0) rotate(0deg); }
}
@keyframes seFaceOk { 0%, 11% { visibility: visible; } 12%, 100% { visibility: hidden; } }
@keyframes seFaceNo { 0%, 11% { visibility: hidden; } 12%, 100% { visibility: visible; } }
@keyframes seEdge   { 0%, 11% { border-color: rgba($subject-ink, .25); } 12%, 100% { border-color: $error; } }

振れる軸は目ではなく喉元

transform-origin: 50% 86% を首の高さに置いて rotate と translateX を混ぜてあるため、顔が平行移動するのではなく首を軸に振られて見えます。

切り替えを同じ1点にそろえる

表情もエラー文も入力欄の枠線も 11% から 12% という同じ点でカットされ、揺れのほうは補間でフレームを埋めるので、動いたフレームは 19/23 になります。

07跳び上がる成功

キャラクターが一度かがんでから跳び上がり、三つの星が遅れて追いかけ、浮いている間は影が小さくなります。

跳び上がる成功
新しいタブで開く

07 · 跳び上がる成功 — 計測 累積面積 11.441% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateYanimation-delayanimation-fill-modescale
@keyframes seJump {
  0%, 4% { transform: translate(0, 0) scale(1, 1); }
  9%, 12% { transform: translate(0, 8px) scale(1.1, .8); }
  16% { transform: translate(0, var(--j)) scale(.92, 1.16); }
  19% { transform: translate(0, var(--j)) scale(.98, 1.04); }
  26% { transform: translate(0, 0) scale(1.02, .96); }
  29% { transform: translate(0, 0) scale(1.12, .84); }
  35%, 100% { transform: translate(0, 0) scale(1, 1); }
}
@keyframes seShadow {
  0%, 4% { transform: scaleX(1); opacity: .2; }
  9%, 12% { transform: scaleX(1.12); opacity: .24; }
  16%, 19% { transform: scaleX(.54); opacity: .1; }
  26% { transform: scaleX(.92); opacity: .18; }
  29% { transform: scaleX(1.14); opacity: .24; }
  35%, 100% { transform: scaleX(1); opacity: .2; }
}

高さをつくるのは影のほう

体が上がるのと同じ区間で影を scaleX(.54)・不透明度 .1 まで縮めてあり、影が小さくなった分だけ高く跳んだように見えます。

消える区間をループに入れない

星が消える区間はループに入れず浮いたまま終えているので代表画像が空にならず、後半はまばたきのカットがフレームを埋めて、動いたフレームは 22/23 です。

08撫でると沈む反応

ポインターで頭をこすると体が横に広がって沈み、指を離すと一度行き過ぎてから戻り、こすった回数だけハートが上がります。

撫でると沈む反応
新しいタブで開く

08 · 撫でると沈む反応 — 計測 累積面積 4.586% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scalepointerdowntransform-origintouch-action
@keyframes mbSquashLoop {
  0%        { transform: scale(1, 1); }
  26%       { transform: scale(1.22, .84); }
  32%       { transform: scale(1.16, .88); }
  40%       { transform: scale(1.22, .84); }
  48%       { transform: scale(1.14, .9); }
  54%       { transform: scale(.93, 1.06); }
  62%       { transform: scale(1.02, .99); }
  68%, 100% { transform: scale(1, 1); }
}
.mb.is-demo .mb__heart--loop {
  animation-name: mbHeartLoop;
  animation-duration: $duration;
  animation-delay: calc(var(--i) * 360ms);
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}

沈む向きは足元から

押しつぶしは足が床に付いたまま起きるので transform-origin は底(50% 100%)にあり、scale(1.22, .84) の沈みきった形から scale(.93, 1.06) まで行き過ぎてから元の形に戻ります。

指でもキーボードでも同じ回数

キャラクター自体を <button> にして touch-action: none を与え、pointermove の横移動を 36px ごとに1回と数え、Enter と Space でも1回なでられるようにしてあります。

09放っておくと眠る待機

一定時間どこも触られないと目が閉じて Z が一つずつ浮かび、画面を押すとキャラクターが起きてタイマーが最初に戻ります。

放っておくと眠る待機
新しいタブで開く

09 · 放っておくと眠る待機 — 計測 累積面積 2.336% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateYanimation-delaysetTimeoutprefers-reduced-motion
@keyframes mzLidLoop {
  0%, 16%   { transform: scaleY(1); }
  18%, 100% { transform: scaleY(.1); }
}
@keyframes mzZLoop {
  0%   { transform: translateY(8px) scale(.4); opacity: 0; }
  12%  { opacity: 1; }
  78%  { transform: translateY(-28px) scale(1); }
  100% { transform: translateY(-34px) scale(1); opacity: 1; }
}
.mb.is-asleep .mb__z {
  animation-name: mzZFloat;
  animation-duration: 2.8s;
  animation-delay: calc(var(--i) * .46s);
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}

眠りは別のキーフレームで見せる

眠っている間の呼吸は、起きているときより浅く遅い別のキーフレームで見せているので、同じ体でも状態が変わったことが目で分かります。

時差に短縮記法を使わない

Z の三つは animation-delay で 0秒・0.46秒・0.92秒とずらしますが、animation の短縮記法を使うと遅延が 0 に戻って時差が丸ごと消えるため、個別のプロパティで書いて animation-fill-mode: backwards を添えてあります。

どこで壊れるか — 落とし穴

320px でだけ横にはみ出した06番

9つのうち06番だけが、480px の舞台では何ともないのに 320px にすると入力欄の右端が舞台から出て、body.scrollWidth が 328 になりました。舞台が display: grid なので、その中のルート要素がグリッドアイテムの自動最小サイズ、つまり中身がこれ以上縮まないという限界に押さえられ、使える幅 294.4px を無視して 309px のまま居座っていたのが原因です。<input> は属性を書かなくても既定の size から来る固有の最小幅を約177px 持っていて、これも同じ向きに効いていました。

狭い幅で足した3行

直した場所 足した宣言 効く理由
ルート .mb min-width: 0 グリッドアイテムの自動最小サイズを外す
.mb__form .mb__box .mb__input min-width: 0 size から来る固有の最小幅を外す
.mb__error .mb__label overflow-wrap: anywhere 長い文言が行を押し広げない

この3行を狭い幅のメディアクエリに足して撮り直すと body.scrollWidth は 328 から 320 に戻り、首を振る translateX の左右 10px の余白もその中に収まりました。オンボーディング 画面は横幅がいちばん先に壊れる場所なので、9つとも 480×300 と 320×200 の両方で撮ってから数えています。

静止した1枚だけでは見つからない

同じはみ出しが05番にもありましたが、こちらは止まっている画面をいくら測っても出てきませんでした。歩く通り道が全幅のまま右へ動く作りだったので scrollWidth が伸びるのは動いている最中だけで、レンダリングした直後の1枚を測る検査はいつも通り素通りしてしまいます。通り道を右端に寄せて負の translateX でだけ動かす形へ書き換え、そのうえで9つとも2つの幅で撮り直しました。

動いたフレームの数え方も、ここでつまずきやすい点です。2秒を24枚に分けて撮り、隣り合う2枚を比べるので、比べる回数は24ではなく23になります。06番の 19/23 も07番の 22/23 も分母はこの 23 で、表情を steps(1, end) でカットする面ほど差分の出ないフレームが増えるため、カットの隣に半透明を作らない小さな動きを並べて埋めてあります。07 の跳ね返りのような動きを、手で調整したイージングではなく計算から導きたいときは、物理シミュレーション 作り方 9選のバネが答えを持っています。9つのファイルが入った圧縮ファイルのパスワードは wyvxy9q2 で、中の style.scss にはここで足した3行がそのまま入っています。

オンボーディング デザインでキャラクターはどこに立つか

キャラクターを置く場所は「空いているところ」ではなく、人の視線が次にどこへ行くかで決まります。01 と 09 は人が何もしていない時間に画面の中央で待つ役なので大きく置いてよく、02 は最初の1画面だけに出て以降は消えるのが自然です。03・04・06 は操作する部品の隣に小さく立つ役で、腕や目線が入力欄やボタンのほうを向いていないと、かわいいだけの飾りになってしまいます。05 は待たされる時間そのものを埋める役なので、進捗の数字とバーから離さないでください。07 と 08 は終わったあとの画面に置くもので、次の操作を邪魔しないように一度だけ動いて静止するようにしてあります。

アクセシビリティ

09番は prefers-reduced-motion: reduce で待機のアニメーションをすべて止め、眠った姿の静止画を1枚だけ残します。読者が始めたわけではない自動の動きなので、これは好みではなくWCAG 2.2.2「一時停止、停止、非表示」が求める要件です。残りの8つも reduce ではループを切り、いちばん読みやすい静止状態を残します。01 は呼吸を止めて目を開けた姿勢、02 は腕を上げた挨拶の形、03 は最初の案内文を出した1枚、04 は腕が目を覆った場面、05 は道の途中に立ってバーが 60% で止まった状態、06 は悲しい顔とエラー文が出たところ、07 は星が浮いた着地の姿勢、08 は沈んだ体とハートが上がった位置です。その @media ブロックの中の宣言に !important を1つずつ付けてあるのは、.is-demo の付いたループ用セレクタのほうが詳細度で勝ってしまうからで、書き方の根拠はMDN の prefers-reduced-motion の解説にあります。

色や動きだけで情報を伝えない、という決まりも9つ全部に通してあります。04 のキャラクターは aria-hidden="true" の飾りで、意味は表示ボタンの aria-pressed とラベルが運びます。06 のエラーは role="alert" の領域に置き、赤い枠だけでなく文字とアイコンを一緒に出します。05 のバーの外側には role="progressbar" と aria-valuenow があり、08 のポインター操作には Enter と Space のキーボード代替を用意しました。体の押しつぶしと膨らみに scale だけを使っているのは、幅や高さを書き換えるとレイアウトが計算し直されるからで、その違いはMDN の scale() の解説にまとまっています。

FAQ

css キャラクター 紹介を1画面だけ置くならどれを選びますか

人がまだ何も押していない画面なら 01、押したあとの画面なら 07 が向いています。9つのうち 01 は累積面積 2.49%・強度 78.3 と動きが薄く、長く出しっぱなしにしても読み手の目を奪いません。逆に 05 は累積面積 33.276% と面全体が動くので、数秒で消える待機画面のためだけに使ってください。

キャラクターは SVG で描いたほうがよいですか

9つのキャラクターは SVG ではなく、border-radius: 50% 50% 46% 46% を与えた要素1つに目・頬・口を重ねただけの組み立てです。パーツが少ないほど動き1つがはっきり見えますし、色が変わるだけなので9つとも同じキャラクターに見えます。体と顔には SVG を1枚も使わず、星・ハートと案内文のアイコンだけ Phosphor Duotone をインラインで入れてあります。04 が寄り添う入力欄そのものがフォーカスにどう応えるかは、CSS 入力フォーム フォーカスアニメーション9選で九通り扱っています。

画面が縦や横に伸びてしまうのはなぜですか

transform で広がった境界ボックスは、見た目が移動しただけでもスクロールの高さと幅にそのまま数えられるからです。07 は跳ぶ高さと星の上がり幅を CSS 変数に出して舞台の中に収まる値にし、05 は動く道を右端に固定して負の方向にだけ動かすことで解きました。空の画面に置く絵と文字の余白の取り方はエンプティステート デザイン 9選にまとめてあります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。