コンテンツにスキップ

Slow In & Slow Out(スローインとスローアウト)

現実の物体は静止から徐々に加速し、止まる前に減速します。easings は用途別に調整した 6 本の曲線を、cubicBezierresolveEasing はその曲線を JS から評価する手段を提供します。

LIVE
linear
inOut
out
in
Play (900ms) を押すと、linear / inOut / out / in の 4 レーンの点が同じ 200px を同じ時間で移動する。もう一度押すと戻る。途中の位置の差に注目。

手描きアニメーションでは、キーポーズの近くに中割りを多く詰め、キーから離れた中間部分では間隔を広げます。コマの間隔が狭いところはゆっくり、広いところは速く見えるので、これだけで「動き出しで加速し、到着前に減速する」運動になります。ポーズから離れる部分を slow out、ポーズへ入る部分を slow in と呼びます。

典型例は跳ねるボールです。頂点では速度がゼロに近づくため頂点付近にコマが詰まり(slow in と slow out)、床に近づくほど間隔が開きます。一方、床との衝突は一瞬で、減速はありません。すべてのキーを滑らかにつなぐのではなく、減速する場所と急に止まる場所を選ぶことが重さの表現になります。

等間隔でコマを置いた動きは、速度が一定のまま始まって一定のまま止まるので、ゼンマイ仕掛けやロボットのように見えます。機械的な印象を狙う場合を除き、空間的な移動には必ず加減速を付けます。

UI でのイージングは、動きの「方向」と「目的」で選びます。デモの 4 レーンを時間の途中で止めたときの進み具合は次の通りです。

経過時間 linear inOut out in
10% 10% 0.9% 40.1% 0.5%
25% 25% 7.1% 76.5% 3.2%
50% 50% 50% 96.1% 13.5%
75% 75% 92.9% 99.7% 34.0%
  • 入場は out: 時間の半分で 96% の位置に到達するので、ユーザーの操作に即座に応えたように見え、残りの時間で静かに落ち着きます。enter レシピはすべてこれを使います。
  • 退場は in: 半分の時間ではまだ 13.5% しか動かず、最後に勢いをつけて去ります。視線を引き留めないよう、入場より短くします(enter 200ms に対し exit 150ms)。
  • 画面内の 2 状態間の移動は inOut: タブのインジケーター、並べ替え、パネルの開閉など、出発点も到着点も見えている移動です。poseToPose / animateTo / arc の既定値です。
  • linear は進捗表示専用: プログレスバー、スピナーの回転、スクラブやスクロール連動のように「時間や入力と値が比例すること」自体に意味がある表示にだけ使います。
  • ドラッグ中は付けない: ポインタに追従する要素にイージングを掛けると遅れて見えます。イージングは指を離した後の戻りに使います。
const easings: {
readonly linear: "linear";
readonly inOut: "cubic-bezier(0.65, 0, 0.35, 1)";
readonly out: "cubic-bezier(0.22, 1, 0.36, 1)";
readonly in: "cubic-bezier(0.55, 0, 1, 0.45)";
readonly anticipate: "cubic-bezier(0.36, 0, 0.66, -0.56)";
readonly overshoot: "cubic-bezier(0.34, 1.56, 0.64, 1)";
};
type EasingName = keyof typeof easings;
名前 用途
linear "linear" プログレスバー・スピナー・スクラブのみ。空間的な動きには使わない
inOut cubic-bezier(0.65, 0, 0.35, 1) 画面内の 2 状態間の移動(slow in と slow out の両方)
out cubic-bezier(0.22, 1, 0.36, 1) 入場。速く到着し、穏やかに落ち着く
in cubic-bezier(0.55, 0, 1, 0.45) 退場。勢いをつけてから去る
anticipate cubic-bezier(0.36, 0, 0.66, -0.56) 一度後ろへ約 9.8% 下がってから進む(曲線に溶け込ませた anticipation)
overshoot cubic-bezier(0.34, 1.56, 0.64, 1) 目標を約 9.8% 行き過ぎて戻る(曲線に溶け込ませた follow through)

値はすべて CSS の文字列なので、WAAPI にそのまま渡され、焼き込みは発生しません。

type EasingFn = (t: number) => number; // 0..1 の経過を進捗へ。0..1 を超えてもよい
type Easing = string | EasingFn;

MotionSpec.easingPoseFrame.easing にはどちらの形も渡せます。CSS の文字列は WAAPI にそのまま渡り、JS 関数(spring()cubicBezier() の返り値)は compile 時に線形キーフレームへ焼き込まれます。

function cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn;

CSS の cubic-bezier() と同じ曲線を JS 関数として返します。

引数 説明
x1, x2 number 制御点の時間軸。0〜1 の範囲外なら RangeError
y1, y2 number 制御点の進捗軸。範囲外も可(行き過ぎ・後退する曲線になる)

返り値は t が 0 以下で 0、1 以上で 1 を返します。Newton 法(8 回)で解き、傾きが平らな箇所は二分法にフォールバックします。

function resolveEasing(easing: Easing | undefined): EasingFn;

任意の Easing を JS 関数に解決します。ライブラリ内部では、中断時の現在位置の算出(currentPose)、焼き込み、終了状態の確定、reduced motion 時の終端計算に使われます。

入力 結果
undefined / "linear" 恒等関数(等速)
関数 そのまま返す
"ease" / "ease-in" / "ease-out" / "ease-in-out" CSS 標準の制御点による cubicBezier
"cubic-bezier(a, b, c, d)" 数値を読み取って cubicBezier
それ以外(steps()、CSS の linear() 関数など) TypeError

文字列は前後の空白を除いた値をキーにキャッシュされます。

イテレーション全体のイージングとフレームごとのイージング

Section titled “イテレーション全体のイージングとフレームごとのイージング”

イージングを置ける場所は 2 つあり、キーポーズが 3 つ以上あると結果が変わります。

場所 WAAPI での対応 効き方
PoseFrame.easing キーフレームの easing そのフレームから始まる区間だけに掛かる。各キーポーズの前後で減速する
MotionSpec.easing options.easing イテレーション全体の時間を歪める。途中のキーポーズは速度を保ったまま通過する
import { easings, play } from "twelve-principles";
const el = document.querySelector<HTMLElement>(".box")!;
const keys = [{ x: 0 }, { x: 100 }, { x: 200 }];
// 区間ごと: x = 100 で一度ほぼ止まる(300ms 時点で速度ゼロ)
play(el, { duration: 600, frames: keys.map((k, i) => (i < 2 ? { ...k, easing: easings.inOut } : k)) });
// 全体: x = 100 を最高速で通過し、両端だけで加減速する
play(el, { duration: 600, easing: easings.inOut, frames: keys });

既定値にも違いがあります。easing を指定しない生の MotionSpec は等速(WAAPI の既定 linear)です。poseToPoseeasing のない区間を easings.inOut で埋めるので、キーごとに減速します。arc は 25 フレームを持つので、区間ごとではなく全体に easing を掛けて「経路上の進み」を一度だけ加減速します。デモも 1 本の曲線をそのまま見せるため、全体のイージングを使っています。

どこか 1 か所でも JS 関数のイージングを使うと、spec 全体が 60fps の線形キーフレーム(12〜120 枚)に焼き込まれ、WAAPI の easing"linear" になります。

function withoutGlobalEasing(spec: MotionSpec): MotionSpec;

spec.easing をフレーム側へ移し、見た目を変えずにイテレーション全体のイージングを取り除きます。フレームを挿入したり区間を差し替えたりする前に必要な処理で、anticipatefollowThrough が内部で呼んでいます。

入力の状態 処理
easingundefined"linear" easing を外すだけ
フレームが 2 つで、先頭フレームに easing がない(または "linear" 全体の easing を先頭フレームへ移す。文字列のままなので焼き込みは起きない
全体の曲線が単調でない(overshoot、スプリングなど) 60fps(最低 12 枚)で密にサンプリングしたフレームに置き換える。キーポーズは個別のフレームとしては残らない
それ以外(単調な曲線) キーポーズを保ったまま、各フレームの offsetE⁻¹(offset) に移し、各区間のイージングに全体の曲線を局所的に合成する

最後のケースでは区間のイージングが JS 関数になるため、compile 時に焼き込まれます。たとえば 700ms の arceasings.inOut)を平坦化すると、2 番目のフレームの offset は 1/24 から 0.2 に、24 番目は 23/24 から 0.8 に移ります。

自分で使う場面は、全体のイージングを持つ spec にフレームを追加・挿入するときです。そのまま追加すると、追加したフレームまで全体の曲線で引き伸ばされてしまいます。

import { animateTo, easings, play, resolveEasing } from "twelve-principles";
const panel = document.querySelector<HTMLElement>(".panel")!;
// 入場: out で速く到着し、穏やかに止まる
function openPanel() {
play(panel, { duration: 200, frames: [{ y: 16, opacity: 0, easing: easings.out }, { y: 0, opacity: 1 }] });
}
// 退場: in で加速して去る。入場より短く
function closePanel() {
play(panel, { duration: 150, frames: [{ y: 0, opacity: 1, easing: easings.in }, { y: 16, opacity: 0 }] });
}
// 画面内の移動: animateTo の既定は inOut
const indicator = document.querySelector<HTMLElement>(".tab-indicator")!;
function moveIndicator(tab: HTMLElement) {
animateTo(indicator, { x: tab.offsetLeft }, { transition: { duration: 250 } });
}
// 進捗: linear だけが許される場所
const bar = document.querySelector<HTMLElement>(".progress-bar")!;
play(bar, { duration: 3000, easing: easings.linear, origin: "0% 50%", frames: [{ scaleX: 0 }, { scaleX: 1 }] });
// DOM 以外(数値カウンタなど)でも同じ曲線を使う
const ease = resolveEasing(easings.out);
function countUp(node: HTMLElement, to: number, ms = 600) {
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / ms, 1);
node.textContent = String(Math.round(to * ease(t)));
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
  • Straight Ahead & Pose to Pose: poseToPose はイージングのない区間を easings.inOut で埋め、キーごとに slow in / slow out させます。
  • Anticipation: anticipatewithoutGlobalEasing で平坦化してから溜めのフレームを挿入します。曲線だけで済ませるなら easings.anticipate
  • Follow Through & Overlapping: スプリングは減速と行き過ぎを 1 本で表すイージングです。
  • Arcs: arceasing は経路上の進み具合を決めます(既定 easings.inOut)。
  • Timing: イージングと尺はセットで決めます。入場 200ms の out、退場 150ms の in が基本です。