こんにちは。京都開発部の柴坂浩行です。フロントエンドエンジニアとして、LINE MUSIC の Web アプリ開発を担当しています。
毎年 LINE MUSIC では、みなさんが1年間に聴いた曲を振り返ることができる企画を実施しています。昨年末にも振り返り特設サイト「#LINEMUSICで振り返る2023」を公開しました。
https://music.line.me/webapp/lmplayback2023(公開終了)
この記事では、振り返り特設サイトで作ったアニメーションの実装のコツについてご紹介します。
アニメーションの実装に至るまで
まず、ウェブサイトのアニメーションはどのように決まっていくのでしょうか?
プランナーが思い浮かべるイメージ、デザイナーの考える見栄え、エンジニアの考える実装。最終的には、各々が期待する形を一致させなければなりません。
今回は、以下のような流れでした。
- ウェブサイトデザインの初稿が完成
- デザインミーティング(※1)でアニメーションの方針を相談
- 方針をもとにデザインファイル上にデザイナーが動きの指示を記載
- エンジニアが動きの大きさ、速さ、タイミングなどを具体化し、アニメーションデモ(※2)を実装
- デザインミーティングや Slack 上で相談しながら調整
アニメーションデモでは動きに関わる部分のみ(※2)コーディングして、実際にブラウザで見たときの印象をデザイナーとプランナーに確認してもらいました。
動きの大きさ、速さ、タイミングなどは動かしてみないと善し悪しがわからないので、エンジニアが実装時に初期案として考えました。そこから足したり引いたりの調整を繰り返して最終的な動きを決めていきました。
※1 プランナー、デザイナー、エンジニアが集まってUIの検討を行う会議
※2 動きに関わらない部分はデザインファイルから書き出したUIの画像をそのまま貼り付けて実装し、早さ優先で特定の画面幅のみで見られるデモにしました
実装した主なアニメーション
ここからは、サイトに実装した中で特に工夫したアニメーションをいくつか紹介します。
- フェードインアニメーション
- SVG明滅アニメーション
- プログレスバーアニメーション
- カウントアップアニメーション
- 紙吹雪アニメーション
- ボタンの省略表示アニメーション
アニメーションを過剰にするとユーザー体験が損なわれることもあるので、これらのアニメーションの特性を理解してバランスを取ることを重視しました。
例えば、フェードインアニメーションはページの全域にわたって使用していますが、ユーザーアクションを期待する場所では目が奪われないよう使用しないことにしました。一方で特徴を出したいところでは、プログレスバーやカウントアップ、紙吹雪などでメリハリをつけています。こういったバランス調整を、レビューで意見を出し合って行いました。
それでは、それぞれの解説に移ります。
このプロジェクトでは Vue.js (Options API) や SASS を使用しており、それぞれのコードも一部ご紹介します。
フェードインアニメーション
多くのコンポーネントに適用した汎用的なアニメーションです。ユーザーアクションを求めるところには適用しないなど、過剰に使用しないよう気を配りました。
アニメーションとしては、印象的な動きにするための立体的な動きがポイントです。
デザイナーからは、透明度をつけるだけでなく下からゆっくり上がるようにしてほしいという要望がありました。実装時にはそれに加えてアニメーション前のスケールを少しだけ小さくすることでより立体感をつけています。大きさ の変化に気づく人は少ないと思いますが、隠し味のような小技です。
実装イメージ
.fadein_section {
.fadein {
opacity: 0;
transform: translate(0, 20px) scale(0.99); // わずかに小さくしておく
transition-property: opacity, transform;
transition-duration: 1s;
transition-delay: 0;
}
&.active .fadein {
opacity: 1;
transform: translate(0, 0) scale(1); // アニメーション後に等倍に
}
}
SVG明滅アニメーション
背景に多用した、光が明滅するアニメーションには、SVG と CSS Animation を組み合わせました。
SVG内の複数の光のパーツを、CSS Animation を用いてそれぞれ異なるタイミングで明滅させています。
異なるタイミングで明滅させるためには、それぞれを別の画像として配置してアニメーションを設定する必要がありますが、数が多くなるほど実装コストが高くなります。そこで、複数の光のパーツをまとめて1個のSVG画像として用意し、その中でタイミングをずらしたアニメーションを光のパーツごとにかけることにしました。
SVGの内部にCSSが効くことはよく知られていますが、今回は透明度を変化させるだけなので問題なく動作します。デザインデータから書き出した SVG の内部に、クラス名を追加する程度の変更で済むため実装は非常に簡単でした。また Vue.js を使っているため SVG と CSS を1つの Component として扱い、管理しやすくし ています。
実装イメージ
元SVG
<svg>
<g>
<path></path>
<path></path>
<path></path>
<path></path>
<path></path>
</g>
</svg>
Vue Component 化
<template>
<svg>
<g class="twinkle">
<path></path>
<path></path>
</g>
<g class="twinkle">
<path></path>
<path></path>
</g>
<g class="twinkle">
<path></path>
</g>
</svg>
</template>
<style lang="scss" scoped>
.twinkle {
opacity: 0;
animation: twinkle 4s ease infinite;
$animation-delay: 1.5s;
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
animation-delay: #{$animation-delay * ($i - 1)};
}
}
}
</style>
この手法では、SVG画像全体が都度再描画されるためCPU負荷が高くなる可能性があります。そのためパフォーマンス測定を行うなど、負荷対策には配慮して実装されることをおすすめします。
プログレスバーアニメーション
スクロールに応じて、真っすぐ引いたバーの上に、緑のバーが伸びていきます。また見出しの横では線上にドットが配置され、緑のバーにあわせて色が付きます。
スクロールに応じて変化する緑のバーの長さは、JavaScript から CSS variable で CSS に適用しています。
スクロール量とバーの長さは1:1の比率で変化するため、そのままでは緑のバーがその場から動いていないように見えてしまいますが、長さ(ここでは height
)を transition
で遅延させることで、スクロールに緑のバーがついていくような動きを演出できます。
.bar {
content: '';
position: absolute;
z-index: 1;
top: var(--timeline-position);
left: calc(50% - 4px);
width: 8px;
height: var(--timeline-length);
transition: height 0.2s ease;
background: $campaign-color;
}
ここでお気づきかと思いますが、線上にある見出し横の「ドット」は「バー」とは別の仕組みで動いています。「ドット」はタイトル側で実装してあり、緑のバーが到着するタイミングでタイトルに active
クラスが付与されるようにしています。
色の変化と同時に大げさに scale
の数値を動かすことで、アニメーションで遅延して延びる緑のバーと同期するように見せかけてあります。
.season_title_area {
&::before {
background-color: #fff;
}
&.active {
&::before {
animation: season-activation 1s ease 0.1s forwards;
}
}
}
@keyframes season-activation {
0% {
background-color: #fff;
transform: scale(1);
}
10% {
background-color: $campaign-color;
transform: scale(2); // 大げさに大きくする
}
100% {
background-color: $campaign-color;
transform: scale(1);
}
}
別々に実装したアニメーションの同期は難しい場合がありますが、演出を大げさにすることで厳密さをなくすと同時に目をひくこと ができ、一石二鳥になりました。
こういった演出も、アニメーションをより自然にする大事なポイントです。
カウントアップアニメーション
聞いたジャンルの割合を数字で出すセクションでは、その1位から3位までを順番に表示するときに数字をカウントアップさせています。
computedRate() {
let value = 0;
const rate: number[] = [];
for (const obj of this.genres) {
rate.push(Math.min(obj.playedRate, Math.max(0, this.count - value)));
value += obj.playedRate;
}
return rate;
}
この computedRate は各ジャンルの割合を配列で出力する computed property で、count
の値を増やすたびに再計算され view に反映します。
2つめ以降のジャンルの数値が count
から上位ジャンルの数値の合計を引いた数でカウントされることで、上位ジャンルのカウントが終わってからカウントアップが開始される仕組みになっています。
次に、 count
の数値を増やす method を用意します。
animateRate() {
if (this.count >= 300) return;
this.count++;
setTimeout(this.animateRate, 1000 / 48);
}
集計方法によって割合の合計が100を超えてもアニメーションするように、3位x100の300までカウントアップするようにしています。また、厳密な フレームレート管理がいらないアニメーションのため、 setTimeout
でアニメーションをまわすようにしました。
そして、 animationRate
を呼び出すタイミングをフェードイン処理の発火にあわせ、2位以下のジャンルは computedRate
で取得できる数値が0より多くなったときにフェードインを発火させるといった実装を行うことで、フェードインアニメーションとの連携を実現しました。
紙吹雪アニメーション
こちらは、オープンソースのライブラリ「canvas-confetti」を活用しました。
https://www.npmjs.com/package/canvas-confetti
発火タイミングはフェードインと同様ですが、遊びの仕掛けとしてタップしたら再度紙吹雪が飛ぶようにしています。
遊びの仕掛けはマウススクロールしていると気づきにくいですが、閲覧者の大半を占めるスマホユーザーはページスクロールで何度も画面を触ることになるため、気づく可能性は高いと考えました。
実際にこの仕掛けに気付いた方の、紙吹雪で埋まったスクリーンショットの投稿をみかけました。大好きなアルバムやアーティストを祝うようにたくさん紙吹雪を飛ばせるので喜んでいただけたのではないでしょうか?
気づいていただいたユーザーがごく一部だとしても実装してよかった仕掛けでした。
ボタンの省略表示アニメーション
シェアボタンを画面右下にフロートさせてよりSNSでシェアしやすいようにしていますが、フロートボタンはページの一部を常に隠すものになるため「×」ボタンを押すことで小さく表示できるようにしています。
この2サイズの切り替えでは、フェードで画像を切り替えるアニメーション要件に対して、動きに連続性を持たせる提案をしました。
ボタンの背景と外枠のデザインが変更前後で共通しており横幅が違うだけなので、width
をCSSアニメーションで変化させます。アニメーションが終わるまでの時間は0.2秒に設定したので、文字などは特にフェードなどの動きを付けなくても違和感はありません。
おまけ:アニメーションは0.2秒を基準に
なお、アニメーションの duration
は 0.2s
を基準としています。
これは昔お世話になったフロントエンドエンジニアから受けた「ユーザーは0.2秒くらいしか待ってくれない」というアドバイスから、いまでも基準にして使っている数値です。
私自身はかつて 0.3秒〜0.5秒を多用していましたが、たった0.2秒でも印象的な動きを見せられることに気づきました。ぜひこれを基準に duration
の数値を考えてみてください。きっとメリハリのあ るページになるでしょう。
おわりに
ここまで、振り返り特設サイトに導入したアニメーションのコツを種類ごとに解説しました。
普段の業務であるアプリケーション制作では使わないような表現のオンパレードで、年に一度のお祭りのような特設ページを楽しんでいただきたいという思いで私たちも楽しみながら開発しました。
LINEヤフーのなかでもエンタメ事業の一角を担うサービスとして、今後もユーザーの皆さんにより楽しんでもらうことを意識しながら開発を進めていきたいです。
今年も1年、LINE MUSIC でたくさん音楽を聞いて、年末の振り返りを楽しみにしていてくださいね!