Webサイトのイージング
自然な動きには緩急があります。
たとえば、床に落ちているスマホを蹴飛ばしてしまったとき、シュッと滑り出して「徐々」に速度が下がり、余韻を残しつつ停止に至ります。この「徐々」をプログラムで表現するのがイージングです。
イージングの役割
イージングはアニメーションの緩急を表現するものです。説明するよりも見てもらった方が早いので、イージングによる印象の違いを2つのウニで比較してみます。
上段のウニは 線形補間(lerp) を行っている単調な動きで、CSSのイージングでは linear
に相当します。下段のウニは easeInOutCubic というイージングを適用したものです。2つを比較すると、動きに緩急がある下段のウニの方がいくらか自然な印象があると思います(※個人差)。
このように、物体の状態変化を自然に補間するのがイージングの役割です。
イージングの実体
const easeInQuad = t => t * t
「イージング」という言葉を聞くと、馴染みがある方は頭に「あの曲線」を浮かべると思いますが、その実体は時間を引数にとって進捗を返す関数です。これからイージングの概要ついて書いていきますが、本質的には関数なんだと頭に入れておくと実装しやすくなると思います。
イージングの種類
種類 | 振る舞い | 備考 |
---|---|---|
in | 加速 | センス |
out | 減速 | 万能 |
in-out | 加速 & 減速 | センス |
イージングには大きく分けて in/out/in-out
の3つの種類があります。これらのイージングは、上図の曲線のように値が変化する過程に違いがあります。in
が加速、 out
が減速、 in-out
がその両方ですが、とりあえず out
が万能でそれ以外はセンスだと思っておくと迷いがありません。
UIのアニメーションでは、ユーザーにストレスを与えないことが大切なので、反応の良さが求められます。そのため、シュッと動いて余韻を残してくれる out
系のイージングが適している場合が多いと思います。
in/in-out
系のイージングは出だしが遅れるため使いどころが難しく、実装には慣れとセンスが求められますが、そのぶん綺麗に決まった時には強い印象を与えられます。
イージングの強さ
in/out/in-out
はあくまでイージングの大枠なので、具体的にどんなスピード感で変化させるかを考える必要があります。ゼロから心地のよいイージングを考えるのは大変ですが、Robert Penner's Easing Functionsという有名なイージングがあります。一覧で確認したいときには、チートシートが便利です。
英語 | 長い英語 | 日本語 |
---|---|---|
Linear | - | 直線 |
Quad | Quadratic | 二次 |
Cubic | - | 三次 |
Quart | Quartic | 四次 |
Quint | Quintic | 五次 |
Expo | Exponential | 指数 |
Sine | - | 三角関数 |
Circ | Circular | 円形 |
Elastic | - | 弾性 |
Bounce | - | バウンド |
Cubic
やSine
などの名前には数学的な意味があり、◯次のイージングは次数が上がるほどカーブが急になります。イージングの名称は、これらのキーワードと in/out/in-out
を組み合わせて easeOutQuart
のような形で表すことが多いです。合言葉は、迷ったら easeOutExpo
です。
CSS
出典:CSS Easing Functions Level 1
CSSの xxx-timing-function
では、値に easing-function を指定しますが、そのひとつが cubic-bezier です。上図のような曲線でイージングを表現します。
これは3次のベジェ曲線で、制御点と呼ばれる点(0, 0)
、(1, 1)
と仕様で決まっているため、cubic-bezier
に渡している4つの値が
cubic-bezier(<number [0,1]>, <number>, <number [0,1]>, <number>)
easing | cubic-bezier |
---|---|
ease | cubic-bezier(0.25, 0.1, 0.25, 1) |
ease-in | cubic-bezier(0.42, 0, 1, 1) |
ease-out | cubic-bezier(0, 0, 0.58, 1) |
ease-in-out | cubic-bezier(0.42, 0, 0.58, 1) |
イージングのキーワードに ease
や ease-in
などがありますが、これらに対応する cubic-bezier
は仕様で明示されています。実際に ease-out
などを使ってみるとわかりますが、これらのイージングはかなり緩やかです。この緩さに満足できず、もっと勢いのあるイージングが使いたい場合は、さきほど挙げた有名なイージングを使うか、 Cubic Bezier のようなサイトで独自のイージングを作成することになります。
どちらにせよ、コードに直接 cubic-bezier(0.42, 0, 1, 1)
のように書いてしまうと保守性が下がるので、変数にしておくとコードの見通しが良くなります。
Sassの変数
Sassなどのプリプロセッサを使っている場合は、イージングを変数用のファイルにまとめておくと便利です。
$ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
.example {
transition-timing-function: $ease-out-expo;
}
CSSの変数
IE11が対象外であれば、CSSの変数も使い勝手が良いのでおすすめです。
:root {
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
.example {
transition-timing-function: var(--ease-out-expo);
}
Sassの変数を使った場合、ブラウザでは変数が展開されてしまっているため、検証ツールで見たときに何のイージングかがわかりにくいです。その点、CSSの変数を使うと何のイージングかがひと目でわかります。
一方で変数が展開される利点もあって、Chromeでは検証ツール上でベジェ曲線を操作できます。CSSの変数にしてしまうと、変数を宣言した場所でしかベジェ曲線を操作できないため、ベジェが少し遠くなります。一長一短あるので、開発の段階や好みの問題ですが、どちらにせよ変数にしておいて損はありません。
CSS or JS
これはイージングというよりもアニメーションの問題ですが、CSSかJSのどちらで実装するか、というのは悩ましい問題です。この問題については、下記の Web Fundamentals のページにわかりやすくまとまっています。なんの答えにもなっていませんが、CSSの手が届かない部分はJSでやる、ぐらいにざっくり考えています。
特に、ReactやVueなどを使っている場合、 state
と className
を同期させた方が綺麗に実装できるため、JSで style
を毎フレーム更新するような豪快な実装は避けたいのが本音です。ただ、複雑なタイムラインのアニメーションを実装したい場合や、Canvasなどをアニメーションさせたい場合は、アニメーションライブラリに頼ることになります。
アニメーションライブラリ
アニメーションライブラリにも色々ありますが、雑にまとめてしまうと、JSのオブジェクトを毎フレーム更新してくれるライブラリです。アニメーションライブラリの使いどころとして思い浮かぶのが、下記のようなケースです。
- 複雑なタイムラインのアニメーション
- Canvasなどのアニメーション
- CSSでは表現できないイージングを使いたい
多くのアニメーションライブラリでは Stagger という機能が実装されていますが、これは複数の要素に段階的な delay
をもたせるものです。CSSでも transition-delay などを等間隔でズラせば実装できます。
// Sassのループを使って delay を設定する例。
// 要素の個数が決め打ちになるので増えたら詰む。
@for $i from 0 through 5 {
.item:nth-child(#{$i + 1}) {
transition-delay: #{$i * 80ms};
}
}
しかし、もし要素が増えたら〜と考え始めたら結局JSが必要になるので、 Stagger
をアニメーションライブラリで実装するのは合理的な選択です。
import gsap from 'https://cdn.skypack.dev/gsap@3.9.0'
const raptors = document.querySelectorAll('.raptor')
const stagger = 0.08
gsap
.timeline({
repeat: -1
})
.fromTo(raptors, {
x: '-150%',
}, {
x: '150%',
duration: 1,
stagger,
ease: 'power4.out'
})
.to(raptors, {
rotateY: 180,
duration: 0.5,
ease: 'expo.out'
})
.to(raptors, {
x: '-150%',
duration: 1,
stagger,
ease: 'power4.out'
})
.to(raptors, {
rotateY: 0,
duration: 0.5,
ease: 'expo.out'
})
これは、ヴェロキラプトルに Stagger
を設定しつつ、往復をループさせるアニメーションの実装例です。ライブラリには GSAP を使っています。CSSのみで実装するよりも、メソッドチェーンでアニメーションを記述できた方がコードの見通しが良いと思います。(※個人差)
また、GSAPではCSSでは表現できない複雑なイージングがデフォルトで用意されており、CustomEase を使えばより自由度の高いイージングを作成できます。
GSAPがなんだかなぁという方には anime.js もおすすめです。
イージング関数を作る
アニメーションライブラリを使いたくないという硬派な人は、イージング関数を用意する必要があります。本来ここが大変になるところですが、ありがたいことに イージングのチートシート にはCSSだけでなく関数も載っています。また、下記のGistも実装の参考になります。
/**
* https://easings.net
*/
const linear = t => t
const easeOutQuart = t => 1 - Math.pow(1 - t, 4)
引数の t
は 0 〜 1
に正規化して渡します。イージングは他にもたくさんありますが、全部コピペするのはあれなので、とりあえず easeOutQuart
だけ載せています。
t | linear | easeOutQuart |
---|---|---|
0 | 0 | 0 |
0.2 | 0.2 | 0.59 |
0.4 | 0.4 | 0.87 |
0.6 | 0.6 | 0.97 |
0.8 | 0.8 | 0.99 |
1 | 1 | 1 |
t
を 0 → 1
に変化させて出力を確認してみると、 easeOutQuart
は linear
に比べて、値の変化に緩急がついていることがわかります。こんな感じでイージングを関数で表現できるわけですが、これだと細かい調整が難しいです。たとえば、序盤は少し緩くしつつ、終盤には個性を出したいといった場合の数式が思い浮かびません。
そこでベジェ曲線です。
CSSのイージングにも使われている3次ベジェ曲線であれば、ある程度自由にイージングを表現できそうです。Wikipediaにある数式をもとにベジェ曲線を描画してみます。
これは easeInOutQuart の曲線ですが、ベジェ曲線を描画するまではシンプルです。しかし、これをイージングとして使おうと思った時に壁にぶつかります。イージング関数に求める振る舞いは、時間経過(x軸)に対する値の進捗(y軸)を返してくれることです。たとえば、渋いイージングを作成した場合、こんな風に関数を叩きたくなります。
y = easeOutVintage(x)
しかしベジェ曲線は先ほど数式を記載したとおり、0 〜 1
の値をとる t
に依存したベクトルなので、 x
から y
を一発で求められません。なので、まずは与えられた x
に対して t
の3次方程式の実数解を求め、その t
から y
を求める必要があります。
イージングの実体は時間を引数にとって進捗を返す関数だと書きましたが、それをベジェ曲線で実装するのは楽ではないことがわかります。
アニメーションライブラリはどうやって実装しているかと思い anime.js のコードを見てみると、 gre/bezier-easing というライブラリを使っていました。このライブラリでは、3次方程式の実数解を求める際に ニュートン法 と 二分探索 を使い分けて最適化しているようです(接線の傾きが平行に近い場合は二分探索)。
const easeOutVintage = BezierEasing(0.12, 0.86, 0.3, 1)
const y = easeOutVintage(0.5)
これを使うと、CSSの cubic-bezier
と同じ形式でイージングを作成できます。せっかくアニメーションライブラリを使わずに実装しようとしてるのに、イージングのライブラリを使うことには矛盾を感じますが、このライブラリ以上に計算を最適化できる自信がなかったので、細かいことは気にしない方向で。
アニメーションを実装する
あとはイージングをつけてアニメーションさせる関数を作ります。値を補間するのが目的なので関数名は tween
にしました。入力値のチェックはしていないので、心の中でエラー処理をしてください。
type Easing = (t: number) => number
type TweenOptions = {
from: number
to: number
duration: number
easing: Easing
onUpdate(currentValue: number): void
}
function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}
function tween({ from, to, duration, easing, onUpdate }: TweenOptions): Promise<void> {
return new Promise((resolve) => {
const startTime = performance.now()
const tick = () => {
const elapsedTime = performance.now() - startTime
const progress = clamp(elapsedTime / duration, 0, 1)
const currentValue = from + (to - from) * easing(progress)
onUpdate(currentValue)
if (progress === 1) {
resolve()
} else {
requestAnimationFrame(tick)
}
}
onUpdate(from)
requestAnimationFrame(tick)
})
}
JavaScript
function clamp(num, min, max) {
return Math.min(Math.max(num, min), max)
}
function tween({ from, to, duration, easing, onUpdate }) {
return new Promise((resolve) => {
const startTime = performance.now()
const tick = () => {
const elapsedTime = performance.now() - startTime
const progress = clamp(elapsedTime / duration, 0, 1)
const currentValue = from + (to - from) * easing(progress)
onUpdate(currentValue)
if (progress === 1) {
resolve()
} else {
requestAnimationFrame(tick)
}
}
onUpdate(from)
requestAnimationFrame(tick)
})
}
ヨークシャーテリアをパタパタ
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4)
function rotate(el: HTMLElement) {
async function loop() {
await tween({
from: 0,
to: 180,
duration: 960,
easing: easeOutQuart,
onUpdate: val => {
el.style.transform = `rotateY(${val}deg)`
}
})
await tween({
from: 180,
to: 360,
duration: 960,
easing: easeOutQuart,
onUpdate: val => {
el.style.transform = `rotateY(${val}deg)`
}
})
loop()
}
loop()
}
const yorkshire = document.querySelector<HTMLElement>('.yorkshire')
if (yorkshire) {
rotate(yorkshire)
}
JavaScript
const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4)
function rotate(el) {
async function loop() {
await tween({
from: 0,
to: 180,
duration: 960,
easing: easeOutQuart,
onUpdate: val => {
el.style.transform = `rotateY(${val}deg)`
}
})
await tween({
from: 180,
to: 360,
duration: 960,
easing: easeOutQuart,
onUpdate: val => {
el.style.transform = `rotateY(${val}deg)`
}
})
loop()
}
loop()
}
const yorkshire = document.querySelector('.yorkshire')
if (yorkshire) {
rotate(yorkshire)
}
作成した関数を使って、ヨークシャーテリアをパタパタする実装例です。ここではイージングをベタ書きしてますが、実際は easing.ts
かなにかを作成して import
して使うイメージです。このぐらいならCSSの animation で実装したほうが簡単ですが...
数字のカウントアップ
※ CodePen右下の「Return」ボタンで再生できます
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4)
const year = document.querySelector<HTMLElement>('.year')
if (year) {
tween({
from: 0,
to: 2022,
duration: 5000,
easing: easeOutQuart,
onUpdate: val => {
year.textContent = Math.floor(val).toString()
}
})
}
JavaScript
const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4)
const year = document.querySelector('.year')
if (year) {
tween({
from: 0,
to: 2022,
duration: 3000,
easing: easeOutQuart,
onUpdate: val => {
year.textContent = Math.floor(val).toString()
}
})
}
値の変化をイージングで補間するだけの関数なので、textContent の更新にイージングを与えるような実装もできます。最後に出るテキストのアニメーションはCSSの transition で実装してます。
以上、CSS・アニメーションライブラリ・手作りでイージングを扱う方法でした!
Discussion