Apple Design
Appleが、コンピュータのように感じられるのをやめ、あなたの延長のように感じられるインターフェースを構築する方法。この知識は、AppleのWWDCデザイントーク、主に Designing Fluid Interfaces (WWDC 2018) から得たもので、ウェブプラットフォーム(CSS、Pointer Events、requestAnimationFrame、Motion/Framer Motionなどのスプリングライブラリ)向けに蒸留・翻訳されています。
一貫したテーマ:モーションが現在の画面上の値から始まり、ユーザーの速度を継承し、慣性を前方に投影し、任意の瞬間に掴んで逆転させることができるとき、インターフェースは生き生きと感じられます。 スプリングは、本質的に割り込み可能で速度を認識するため、これを自然にするためのツールです。
核となるアイデア
「インターフェースを私たちの考え方や動き方に合わせると、魔法のようなことが起こります。コンピュータのように感じられるのをやめ、私たちのシームレスな延長のように感じられるのです。」
インターフェースは、物理世界のように振る舞うときに流動的になります。つまり、物事が瞬時に応答し、連続的に動き、慣性を運び、境界で抵抗し、動作の途中で方向転換できることです。以下のすべては、それに近づくための方法です。
Appleはデザインを4つの人間のニーズに奉仕するものとして捉えています:安全性/予測可能性、理解、達成、そして喜び。 ここにあるすべてのルールは、そのうちの1つに奉仕します。
1. 応答 — レイテンシをなくす
遅延が現れた瞬間、直接性の感覚は「崖から落ちます」。応答は、他のすべてが構築される基盤です。
- ポインターダウン時に応答し、リリース時ではない。 ボタンが押された瞬間にハイライトします。
click/タッチアップまでフィードバックを待つと、死んでいるように感じられます。 - あらゆるレイテンシに警戒する。 デバウンス、人工的なタイマー、トランジションの待機、および約300msのタップ遅延を監査します。入力パス上にあって本質的でないものはすべて退行です。
- フィードバックは、インタラクションの最中に継続的でなければならず、最後だけではない。 ドラッグ、スライダー、またはドロワーの場合、UIをポインタと1:1で常に更新します — ジェスチャーが完了したときにのみアニメーションさせないでください。
/* フィードバックは押下とともに生き、それは即時です */
.button:active {
transform: scale(0.97);
transition: transform 100ms ease-out;
}
2. 直接操作 — 1:1のトラッキング
「タッチとコンテンツは一緒に動くべきです。」
ユーザーが何かをドラッグするとき、それは指に固定されたままである必要があり — そしてそれを掴んだ位置からのオフセットを尊重しなければなりません。掴んだときに要素の中央にスナップすると、すぐに幻想が壊れます。
- Pointer Eventsを
setPointerCaptureと共に使用し、ポインターが要素の境界を離れてもトラッキングを継続します。 - 現在のポイントだけでなく、短い速度/位置の履歴(最後のいくつかの
pointermoveイベント)を追跡します — リリース時に速度が必要になります。
el.addEventListener('pointerdown', (e) => {
el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top; // 掴んだ場所を尊重
// ...速度のための位置+タイムスタンプ履歴を追跡
});
3. 割り込み可能性 — 最も重要な原則
「思考とジェスチャーは並行して起こります。」
すべてのアニメーションは、いつでも割り込み可能でリダイレクト可能でなければなりません。ユーザーは、アニメーションが終了するのを待たずに、動いている要素を空中で掴んで逆転させることができなければなりません。ユーザーが再び掴んだ閉じるモーダルは、指に従うべきです — 最初に閉じ終えてから再び開くのではありません。
- トランジション中に入力をロックアウトしないでください。
- 常にプレゼンテーション(現在の)値からアニメーションし、ターゲット値からは決してアニメーションしないでください。 割り込み時に、要素のライブなオンスクリーンtransformを読み取り、そこから新しいアニメーションを開始します。論理的な/ターゲット値から開始すると、目に見えるジャンプが発生します。
- ジェスチャー駆動のものにはCSSトランジションと
@keyframesを避けてください — それらは飛行中にスムーズに掴んで逆転させることができません。スプリングはデフォルトで現在の値からアニメーションするため、割り込みに正確に必要なものです。 - ジェスチャーが逆転するとき、速度をブレンドし — ハードカットしないでください。 逆転で1つのアニメーションを別のものに置き換えると、速度の不連続性、つまり「レンガの壁」が生じます。再ターゲット時に速度を引き継ぐスプリングライブラリはそれを回避します。(これこそがiOSの加算アニメーションがネイティブに行うことです。ウェブでは、現在の速度から再ターゲットするスプリングライブラリを選択してください。)
- 2Dモーションを独立したXとYのスプリングに分解します。 2D距離に単一のスプリングを使用すると、XとYの速度が異なる場合に非同期になります。
4. アニメーションよりも動作 — スプリングを使用する
「アニメーションを、インターフェースによって規定されたものではなく、あなたとオブジェクトの間の会話として考えてください。」
事前にスクリプト化された、固定時間のアニメーションは新しい入力に応答できません。スプリングはできます — 新しい入力は単にターゲットを変更し、モーションは連続性を保ちます。ユーザーが触れる可能性のあるものにはスプリングを活用してください。
Appleは意図的に物理トリプレット(質量/剛性/減衰)を、2つのデザイナーに優しいパラメータに置き換えました。これらで考えてください:
- 減衰比 — オーバーシュートを制御します。
1.0= 臨界減衰、バウンスなし、スムーズな整定。< 1.0= オーバーシュートして振動します。値が低いほど跳ねやすくなります。 - 応答 — 値がターゲットにどれだけ速く到達するか(秒単位)。値が低いほどスナッピーです。これは「持続時間」ではありません — スプリングには固定の持続時間がなく、その整定時間はパラメータから生じます。
デフォルト:
- ほとんどのUIを減衰
1.0(臨界減衰)で開始します — 優雅で注意をそらしません。 - ジェスチャー自体が慣性を運んだ場合にのみ(フリック、スロー、ドラッグリリース)、バウンス(減衰〜
0.8)を追加します。単にフェードインしたメニューでのオーバーシュートは間違っていると感じられます。フリックしたカードでのオーバーシュートは正しいと感じられます。
Appleが出荷する具体的な値:
| インタラクション | 減衰 | 応答 |
|---|---|---|
| 移動 / 再配置(例:PiP) | 1.0 | 0.4 |
| 回転 | 0.8 | 0.4 |
| ドロワー / シート | 0.8 | 0.3 |
ウェブマッピング(Motion / Framer Motion): bounce + duration スプリングAPIは、Appleの減衰 + 応答に密接にマッピングされます。安全なハウススタイルは、デフォルトでdamping: 1.0スプリングを至る所で使用し、バウンスは慣性駆動の物理的インタラクションのために予約することです。
import { animate } from 'motion';
// 臨界減衰デフォルト(オーバーシュートなし)
animate(el, { y: 0 }, { type: 'spring', bounce: 0, duration: 0.4 });
// 慣性インタラクション — 少しバウンス、フリックが先行した場合のみ
animate(el, { y: target }, { type: 'spring', bounce: 0.2, duration: 0.4 });
5. 速度の引き継ぎ — ドラッグとアニメーションの間の継ぎ目
ジェスチャーが終了したとき、アニメーションは指の正確な速度で継続しなければなりません。そうすることで、ドラッグとアニメーションの間に目に見える継ぎ目がなくなります。これは、「流動的」を「普通」から最も大きく隔てる詳細です。
ポインターのリリース速度をスプリングの初速度として渡します。一部のスプリングAPIは相対速度を必要とします — ターゲットまでの残り距離で正規化します:
relativeVelocity = gestureVelocity / (targetValue − currentValue)
例:要素がy=50、ターゲットy=150(残り100px)、指が50px/sで動いている → 初速度 = 50 / 100 = 0.5。Framer Motion / Motionは絶対px/s速度を直接受け取るため(velocityオプション)、通常は生の値を渡します。
6. 慣性投影 — ジェスチャーが向かっている場所にアニメーションする
「小さな入力を取り、大きな出力を作り出せ。」
リリースポイントから最も近い境界にスナップしないでください。速度を使用して静止位置を投影します — スクロールの減速とまったく同じように — そして、その投影されたポイントに最も近いターゲットにスナップします。これが、フリックが要素を投げているように感じさせるものです。
Appleの正確な投影関数(Designing Fluid Interfacesサンプルコードより):
// decelerationRate ≈ 0.998 for normal scroll feel; 0.99 for snappier
function project(initialVelocity /* px/s */, decelerationRate = 0.998) {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate);
}
const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // 投影からターゲットを選択
animateSpringTo(target, { velocity: releaseVelocity }); // その後、速度を引き継ぐ (§5)
注意:物理学の教科書にあるv²/(2·decel)はAppleが出荷するものではありません — 上記の指数減衰形式を使用してください。これは、優れたボトムシートやカルーセル(Vaul、Embla)における標準的な動作です。
7. 空間的一貫性 — 対称的なパス、固定された原点
「何かがある方法で消えるなら、私たちはそれが来た場所から現れることを期待します。」
- 同じパスに沿って出入りします。 右からスライドインするパネルは、右に閉じなければなりません。右から入って下から出るのは、切り離されて混乱しているように感じられます。
- インタラクションをそのソースに固定します。 メニュー、ポップオーバー、またはシートは、それをトリガーした要素から発生する必要があります —
transform-originをトリガーに設定し、ボタンとコンテンツの間の空間的関係が明らかになるようにします。(これは、ポップオーバーが中央からではなくトリガーからスケーリングするという、同じ原点認識ポイントです。) - 可逆的なトランジションではイージングをミラーリングして、出力パスが戻りパスと一致するようにします(2つの方向に逆の3次ベジェ制御点を使用します)。
8. ジェスチャーの方向を暗示する
人間は軌道から最終状態を予測します。中間のモーションは、物事がどこに向かっているかを示すはずです — Control Centerモジュールは「指に向かって上と外に成長します」。中間フレームが結果を指し示し、盲目的に補間するだけではないようにします。
9. ラバーバンディング — ソフトな境界
端では、ハードストップするのではなく、段階的に抵抗します。ハードストップは「フリーズした」と読まれます。継続的な抵抗は「応答性があるが、ここにはもう何もない」と読まれます。ユーザーが境界を超えてドラッグするほど増加する減衰を適用します。
// 境界を超えるほど、要素の追従は少なくなります — 実際のものは停止する前に減速します
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
10. ジェスチャーデザインの詳細(「感触」チェックリスト)
- タップ: タッチダウン時にハイライト(即時)、タッチアップ時にコミット。ターゲットの周りに約10pxのヒステリシス/ヒットパディングを追加し、ドラッグアウェイとバックによるキャンセルを許可します。
- ドラッグ/スワイプ: 方向にコミットする前に小さな移動しきい値(ヒステリシス、〜10px)を必要とし、その後1:1で追跡します。
- 最初の動きから、すべての妥当なジェスチャーを並行して検出し、意図が明確になったら自信を持って敗者をキャンセルします。最終状態のみを報告する認識子(
swipeleftタイプのイベント)は避けてください — それらはフィードバックに必要な継続的な追跡を捨ててしまいます。 - 曖昧さ解消の遅延を最小限にします。 ダブルタップ検出は不可避的にシングルタップを遅らせます。ダブルタップが実際に存在する場合にのみそのコストを支払います。
11. フレームレベルのスムーズさ
スムーズさとは、単なるフレームレートではなく、フレーム内のものに関するものです。
- ストロボ効果を避けるために、フレームごとの位置変化を知覚しきい値以下に保ちます。
- 非常に速いモーションの場合、微妙なモーションブラー/ストレッチが速度をエンコードし、鋭い筋よりも読みやすくなります。
requestAnimationFrameはウェブの表示同期クロックです(AppleはCADisplayLinkを使用します)。コンポジターに優しいプロパティ —transformとopacity— のみをアニメーションし、モーションが差し迫っている場所ではwill-changeでヒントを与えます。
12. マテリアルと奥行き — 半透明が階層を伝える
Appleは、焦点を奪うことなく構造をもたらす浮遊機能レイヤーとして半透明マテリアルを使用します。ウェブでは、backdrop-filterで近似します。
- ナビ/ツールバー/シートを半透明レイヤーとして構築します(
backdrop-filter: blur()+ 半透明の背景)。コンテンツはその下をスクロールします — 固定ストリップを消費する不透明なバーではありません。 - マテリアルの重みが階層をエンコードします: より暗く/より重いマテリアルは構造領域(サイドバー)を分離します。より明るいマテリアルはインタラクティブ要素(ボタン)に注意を引き付けます。明るい半透明面を別の面の上に重ねないでください — 可読性が崩れます。
- より大きな面は、より厚く読まれるべきです: 小さなチップよりも強力なブラーと深いシャドウ。コンテキストを認識したシャドウを検討してください — 賑やかな/テキストコンテンツの上では分離のためにより重く、プレーンな背景の上ではより軽く。
- フォーカスするために暗くし、フローを維持するために分離します。 モーダルタスクは、面を暗くするスクリムと組み合わせ、背景を後ろ/下に押しやります。並行した、非ブロッキングなパネルは、フローが中断されないように、スクリムなしで半透明とオフセットを使用します。積み重ねられたシートの場合、各親レイヤーを段階的に暗くし、後ろに押しやります。
- ビビッドネス(鮮やかさ)により、変化する背景の上でもテキストの可読性が維持されます。 ぼやけた/半透明の面の上では、フラットなグレーのテキストを使用しないでください — より高いコントラスト、わずかに太いウェイト、および小さな文字間隔の増加を使用してください。色は半透明の前景ではなく、ソリッドなレイヤーに配置します。
- ハードな区切り線ではなく、スクロールエッジ効果。 スティッキーヘッダーの下に1pxの境界線を引く代わりに、コンテンツがフローティングクロームと重なる場所(フローティングUIが実際にコンテンツと重なる場合のみ)に、小さなブラー/グラデーションマスクをフェードさせます。
- 単にフェードするのではなく、物質化します。 ガラス/ブラー面の場合、出入り時にブラー半径とスケールを一緒にアニメーションさせ、面が単なる不透明度フェードではなく、到着する実際のマテリアルとして読まれるようにします。
.toolbar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(255, 255, 255, 0.4); /* 明るい上端 = マテリアルに当たる光 */
}
13. マルチモーダルフィードバック — モーション + サウンド + ハプティクス
感覚を組み合わせるための3つのルール(Designing Audio-Haptic Experiencesより):
- 因果関係 — 何がフィードバックを引き起こしたかが明らかでなければなりません。実際の原因イベント(トグルが反転する、アイテムが所定の位置にスナップする)でトリガーし、その特徴をアクションの物理性に一致させます。
- 調和 — ビジュアル、サウンド、ハプティクスは同じフレームで発火しなければなりません。それらの間のレイテンシは幻想を破壊します。CSSトランジションがオーディオ/ハプティクス(Vibration API)より遅れないようにします。
- ユーティリティ — フィードバックは、それが価値を得る場所にのみ追加します。ハプティクス/サウンドは意味のある瞬間(成功、エラー、コミット、スナップ)のために予約します。過剰なフィードバックは、ユーザーがそれらすべてを無視するように訓練します。
14. 動きの抑制とアクセシビリティ
動きの抑制はフィードバックがないことを意味しません。
…
[翻訳のため省略]