Slow In & Slow Out(スローインとスローアウト)
現実の物体は静止から徐々に加速し、止まる前に減速します。easings は用途別に調整した 6 本の曲線を、cubicBezier と resolveEasing はその曲線を JS から評価する手段を提供します。
アニメーションにおける原則
Section titled “アニメーションにおける原則”手描きアニメーションでは、キーポーズの近くに中割りを多く詰め、キーから離れた中間部分では間隔を広げます。コマの間隔が狭いところはゆっくり、広いところは速く見えるので、これだけで「動き出しで加速し、到着前に減速する」運動になります。ポーズから離れる部分を slow out、ポーズへ入る部分を slow in と呼びます。
典型例は跳ねるボールです。頂点では速度がゼロに近づくため頂点付近にコマが詰まり(slow in と slow out)、床に近づくほど間隔が開きます。一方、床との衝突は一瞬で、減速はありません。すべてのキーを滑らかにつなぐのではなく、減速する場所と急に止まる場所を選ぶことが重さの表現になります。
等間隔でコマを置いた動きは、速度が一定のまま始まって一定のまま止まるので、ゼンマイ仕掛けやロボットのように見えます。機械的な印象を狙う場合を除き、空間的な移動には必ず加減速を付けます。
UI への翻訳
Section titled “UI への翻訳”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% しか動かず、最後に勢いをつけて去ります。視線を引き留めないよう、入場より短くします(enter200ms に対しexit150ms)。 - 画面内の 2 状態間の移動は
inOut: タブのインジケーター、並べ替え、パネルの開閉など、出発点も到着点も見えている移動です。poseToPose/animateTo/arcの既定値です。 linearは進捗表示専用: プログレスバー、スピナーの回転、スクラブやスクロール連動のように「時間や入力と値が比例すること」自体に意味がある表示にだけ使います。- ドラッグ中は付けない: ポインタに追従する要素にイージングを掛けると遅れて見えます。イージングは指を離した後の戻りに使います。
easings
Section titled “easings”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 にそのまま渡され、焼き込みは発生しません。
Easing 型
Section titled “Easing 型”type EasingFn = (t: number) => number; // 0..1 の経過を進捗へ。0..1 を超えてもよいtype Easing = string | EasingFn;MotionSpec.easing と PoseFrame.easing にはどちらの形も渡せます。CSS の文字列は WAAPI にそのまま渡り、JS 関数(spring() や cubicBezier() の返り値)は compile 時に線形キーフレームへ焼き込まれます。
cubicBezier
Section titled “cubicBezier”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 回)で解き、傾きが平らな箇所は二分法にフォールバックします。
resolveEasing
Section titled “resolveEasing”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)です。poseToPose は easing のない区間を easings.inOut で埋めるので、キーごとに減速します。arc は 25 フレームを持つので、区間ごとではなく全体に easing を掛けて「経路上の進み」を一度だけ加減速します。デモも 1 本の曲線をそのまま見せるため、全体のイージングを使っています。
どこか 1 か所でも JS 関数のイージングを使うと、spec 全体が 60fps の線形キーフレーム(12〜120 枚)に焼き込まれ、WAAPI の easing は "linear" になります。
withoutGlobalEasing
Section titled “withoutGlobalEasing”function withoutGlobalEasing(spec: MotionSpec): MotionSpec;spec.easing をフレーム側へ移し、見た目を変えずにイテレーション全体のイージングを取り除きます。フレームを挿入したり区間を差し替えたりする前に必要な処理で、anticipate と followThrough が内部で呼んでいます。
| 入力の状態 | 処理 |
|---|---|
easing が undefined か "linear" |
easing を外すだけ |
フレームが 2 つで、先頭フレームに easing がない(または "linear") |
全体の easing を先頭フレームへ移す。文字列のままなので焼き込みは起きない |
全体の曲線が単調でない(overshoot、スプリングなど) |
60fps(最低 12 枚)で密にサンプリングしたフレームに置き換える。キーポーズは個別のフレームとしては残らない |
| それ以外(単調な曲線) | キーポーズを保ったまま、各フレームの offset を E⁻¹(offset) に移し、各区間のイージングに全体の曲線を局所的に合成する |
最後のケースでは区間のイージングが JS 関数になるため、compile 時に焼き込まれます。たとえば 700ms の arc(easings.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 の既定は inOutconst 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);}import { useState, type ReactNode } from "react";import { easings } from "twelve-principles";import { Presence, useMotion } from "twelve-principles/react";
const SEGMENT_WIDTH = 96;
export function Segmented({ options }: { options: string[] }) { const [index, setIndex] = useState(0); const thumb = useMotion<HTMLSpanElement>(); const select = (i: number) => { setIndex(i); // 出発点も到着点も見えている移動なので inOut thumb.animateTo({ x: i * SEGMENT_WIDTH }, { transition: { duration: 250, easing: easings.inOut } }); }; return ( <div className="segmented"> <span ref={thumb.ref} className="segmented-thumb" /> {options.map((label, i) => ( <button key={label} aria-pressed={i === index} onClick={() => select(i)}> {label} </button> ))} </div> );}
export function Details({ open, children }: { open: boolean; children: ReactNode }) { // enter レシピは easings.out、exit レシピは easings.in を使う return ( <Presence show={open} enter="rise" exit="fade" className="details"> {children} </Presence> );}ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”- Straight Ahead & Pose to Pose:
poseToPoseはイージングのない区間をeasings.inOutで埋め、キーごとに slow in / slow out させます。 - Anticipation:
anticipateはwithoutGlobalEasingで平坦化してから溜めのフレームを挿入します。曲線だけで済ませるならeasings.anticipate。 - Follow Through & Overlapping: スプリングは減速と行き過ぎを 1 本で表すイージングです。
- Arcs:
arcのeasingは経路上の進み具合を決めます(既定easings.inOut)。 - Timing: イージングと尺はセットで決めます。入場 200ms の
out、退場 150ms のinが基本です。