コンテンツにスキップ

Appeal(魅力)

魅力は個々の動きの出来よりも、すべての動きに一貫した性格があることから生まれます。本ライブラリでは Personality という一つのオブジェクトがテンポ・誇張・弾性・溜め・潰れを決め、すべてのレシピと振る舞いがそれを読みます。

LIVE
「保存する」を押すとトーストが pop で現れ、もう一度押すと消える。Personality を切り替えて、押したときの沈み込み、離したときの弾み、トーストの出入りを比べる。

Appeal は、観客がそのキャラクターを見ていたいと感じる魅力のことです。「かわいい」ことと同じではありません。悪役にも脇役にも魅力は必要で、観客が性格を読み取れ、次に何をするかに興味を持てることが条件です。

魅力はデザインだけでなく、表情・癖・動き方から生まれます。歩き方一つでも、せっかちな人物は小刻みに速く、のんびりした人物は大きくゆったりと歩きます。形が明快で読みやすいことも重要で、複雑すぎるデザインや対称すぎるポーズは魅力を損ないます。

そして、性格は一貫していなければなりません。場面ごとに動き方が変わるキャラクターは、同じ人物に見えなくなります。

プロダクトの UI も、ユーザーにとっては一つのキャラクターです。ボタンはきびきび沈むのにモーダルはふわふわ弾む、という状態は、場面ごとに性格が変わるキャラクターと同じで、作り込みが足りない印象を与えます。

Personality は、他の原則の強さを決める 5 つの値と、UI ガイドラインの上限を守るかどうかを決める guardrails をまとめたものです。プロダクトで一度だけ決め、すべての動きに渡します。

フィールド 対応する原則 使われる場所
tempo Timing すべてのレシピ・振る舞い・stage の尺に掛かる倍率
exaggeration Exaggeration enter / exit の移動距離とスケール、jump の高さ、shake の振幅、hoverable の浮上量、tiltable の角度、派手なレシピ の振れ幅
bounce Follow Through settleSpringpressabletiltable の戻り)、enter("pop") のオーバーシュート(max(bounce, 0.25))、bounceIn のスプリング(max(bounce, 0.5)
anticipation Anticipation exit("pop") の溜めの大きさ(0 なら溜めなし)、jump のしゃがみの深さとタイミング
squash Squash & Stretch jump の潰れと伸び(max(squash, 0.02) * 2)、pressDepthsquash * 0.75 をクランプ)、fallIn の着地の潰れ
guardrails UI ガイドライン pressDepth の上限。true なら 0.05(scale 0.95 まで)、false なら 0.2(scale 0.8 まで)

6 つのプリセットの選び方の目安です。上の 4 つは guardrails: true、下の 2 つは guardrails: false です。

  • natural(既定) — 汎用。迷ったらこれ。
  • snappy — 業務ツール、エディタ、操作回数の多い画面。速く、弾みは小さい。
  • calm — 金融・医療・読み物など、落ち着きと信頼を優先する画面。弾みも溜めもない。
  • playful — コンシューマー向け、子ども向け、ゲーム的な体験。大きく弾み、溜めも深い。ただし押下などのフィードバックはガイドライン内に収まる。
  • bouncy — キャンペーンページや祝福の場面。playful よりさらに大きく弾み、押下もはっきり潰れる。
  • cartoon — ゲーム、マスコット、一度きりの演出。深い溜め、ゴムのような潰れ、長いオーバーシュート。

bouncycartoon は UI ガイドラインの外へ出るためのプリセットです。アプリ全体ではなく、入れ子の MotionProvider で範囲を限定して使います(派手な動き)。

各プリセットを適用したときの実際の値は次のとおりです(すべてソースの式から算出)。

項目 natural snappy calm playful bouncy cartoon
押下の縮小(pressDepth 0.03 0.0225 0.02 0.05 0.09 0.165
押し込みの尺 100ms 80ms 125ms 100ms 95ms 115ms
enter("rise") の尺 / 距離 200ms / 12px 160ms / 10.8px 250ms / 8.4px 200ms / 16.8px 190ms / 20.4px 230ms / 28.8px
enter("pop") の尺 / 開始スケール 300ms / 0.85 240ms / 0.865 375ms / 0.895 300ms / 0.79 285ms / 0.745 345ms / 0.64
enter("pop") の bounce 0.25 0.25 0.25 0.45 0.55 0.65
exit の尺 150ms 120ms 188ms 150ms 143ms 173ms
exit("pop") の溜め 0.15 0.08 なし 0.25 0.3 0.45
jump の尺 / 高さ 450ms / 16px 360ms / 14.4px 563ms / 11.2px 450ms / 22.4px 428ms / 27.2px 518ms / 38.4px
shake の尺 / 振幅 400ms / 6px 320ms / 5.4px 500ms / 4.2px 400ms / 8.4px 380ms / 10.2px 460ms / 14.4px
戻りのスプリング(settleSpring 350ms, bounce 0.2 280ms, bounce 0.1 438ms, bounce 0 350ms, bounce 0.45 333ms, bounce 0.55 402ms, bounce 0.65
戻りが静止するまで 475ms 375ms 646ms 704ms 808ms 1167ms

settleSpring の 350ms × tempo はスプリングの知覚上の尺で、振動が 0.1% 以内に収まるまでの時間(最後の行)はそれより長くなります。playful 以降は弾みが大きいぶん静止まで長くかかり、cartoon では 1 秒を超えます。

interface Personality {
readonly name: string;
readonly tempo: number; // 尺の倍率。1 未満で速く、1 より大きいと遅く
readonly exaggeration: number; // 距離・角度・スケールの倍率
readonly bounce: number; // 戻りのスプリングの弾み 0〜0.95
readonly anticipation: number; // 溜めの大きさ(主動作に対する割合)0〜1
readonly squash: number; // 潰れと伸びの強さ(0.05 = ±5%)
readonly guardrails: boolean; // true = 押下などを UI ガイドライン内に収める。false = 上限を外す
}
type PersonalityName = "natural" | "snappy" | "calm" | "playful" | "bouncy" | "cartoon";
type PersonalityInput = PersonalityName | Personality;
const personalities = {
natural: { name: "natural", tempo: 1, exaggeration: 1, bounce: 0.2, anticipation: 0.15, squash: 0.04, guardrails: true },
snappy: { name: "snappy", tempo: 0.8, exaggeration: 0.9, bounce: 0.1, anticipation: 0.08, squash: 0.03, guardrails: true },
calm: { name: "calm", tempo: 1.25, exaggeration: 0.7, bounce: 0, anticipation: 0, squash: 0.02, guardrails: true },
playful: { name: "playful", tempo: 1, exaggeration: 1.4, bounce: 0.45, anticipation: 0.25, squash: 0.08, guardrails: true },
bouncy: { name: "bouncy", tempo: 0.95, exaggeration: 1.7, bounce: 0.55, anticipation: 0.3, squash: 0.12, guardrails: false },
cartoon: { name: "cartoon", tempo: 1.15, exaggeration: 2.4, bounce: 0.65, anticipation: 0.45, squash: 0.22, guardrails: false },
} as const satisfies Record<string, Personality>;
名前 tempo exaggeration bounce anticipation squash guardrails
natural 1 1 0.2 0.15 0.04 true
snappy 0.8 0.9 0.1 0.08 0.03 true
calm 1.25 0.7 0 0 0.02 true
playful 1 1.4 0.45 0.25 0.08 true
bouncy 0.95 1.7 0.55 0.3 0.12 false
cartoon 1.15 2.4 0.65 0.45 0.22 false
function definePersonality(overrides: Partial<Personality>, base?: PersonalityInput): Personality; // base 既定 "natural"

base を解決し、overrides を上書きしてから各値を検証し、Object.freeze した Personality を返します。nameguardrails を指定しなければ base の値を引き継ぎます。

フィールド 許容範囲
tempo 0.1〜4
exaggeration 0〜4
bounce 0〜0.95
anticipation 0〜1
squash 0〜0.5
guardrails true / false

数値の範囲外や数値でない値(NaN を含む)は RangeErrorguardrails が boolean でなければ TypeError になります。

const brand = definePersonality({ name: "brand", tempo: 0.9, bounce: 0.3 }, "snappy");
// { name: "brand", tempo: 0.9, exaggeration: 0.9, bounce: 0.3, anticipation: 0.08, squash: 0.03, guardrails: true }
// playful をベースにガードレールだけ外す: pressDepth は 0.05 → 0.06
const party = definePersonality({ name: "party", guardrails: false }, "playful");
definePersonality({ bounce: 1 });
// RangeError: Personality.bounce must be within [0, 0.95], got 1
function resolvePersonality(input?: PersonalityInput): Personality; // input 既定 "natural"

プリセット名なら personalities の値を返し、未知の名前なら RangeErrorUnknown personality "...")を投げます。オブジェクトはそのまま返し、検証しません。レシピと振る舞いはすべて、受け取った personality オプションをこの関数で解決します。

API 受け取り方
enter / exit / jump / shake options.personality
rubberBand / jello / tada / heartbeat / swing / wobble / flip / bounceIn / fallIn options.personality
pressDepth / settleSpring 第 1 引数
pressable / hoverable / tiltable options.personality
stage options.personality(尺にだけ影響)
React のフックと Motion / Presence MotionProvider から自動で受け取る

省略するとすべて "natural" として扱われます。

function MotionProvider({ personality = "natural", reducedMotion = "auto", children }: MotionProviderProps);
function useMotionConfig(): MotionConfig; // { personality: Personality; reducedMotion: ReducedMotion }
プロパティ 既定値 説明
personality PersonalityInput "natural" サブツリー全体の Personality
reducedMotion ReducedMotion "auto" サブツリー全体の reduced motion の扱い
children ReactNode なし

MotionProvider の外では、useMotionConfig()personalities.natural"auto" を返します。MotionProvider は入れ子にでき、内側の値が優先されます。usePress / useHover / useTiltMotionpress / hover / tilt では、オプションに personality を書くとその要素だけ上書きできます。

useMotion().playuseEnterusePresenceuseCascade は、spec の代わりに (personality) => spec を受け取れます。関数はプロバイダの Personality で呼ばれます。

ブランドの Personality を一か所で定義する

Section titled “ブランドの Personality を一か所で定義する”
// motion.ts — プロダクトで唯一の定義
import { definePersonality } from "twelve-principles";
export const brand = definePersonality({ name: "brand", tempo: 0.9, bounce: 0.3 }, "snappy");
save-button.ts
import { enter, exit, hoverable, play, pressable } from "twelve-principles";
import { brand } from "./motion";
export function setupSaveButton(button: HTMLButtonElement, toast: HTMLElement): void {
pressable(button, { personality: brand });
hoverable(button, { personality: brand });
button.addEventListener("click", async () => {
toast.hidden = false;
play(toast, enter("pop", { personality: brand }));
await new Promise((resolve) => setTimeout(resolve, 2000));
await play(toast, exit("pop", { personality: brand })).finished;
toast.hidden = true;
});
}

ユーザー設定で性格を切り替える

Section titled “ユーザー設定で性格を切り替える”

「動きを控えめにする」設定を用意する場合、プリセット名を状態として持てば Personality オブジェクトの同一性が保たれます。

import { jump, play, type PersonalityName } from "twelve-principles";
let personality: PersonalityName = localStorage.getItem("motion") === "calm" ? "calm" : "natural";
export function setCalm(calm: boolean): void {
personality = calm ? "calm" : "natural";
localStorage.setItem("motion", personality);
}
export function notify(badge: HTMLElement): void {
play(badge, jump({ personality }));
}

祝福の画面だけ playful にするなど、意図した例外は入れ子の MotionProvider で範囲を限定します。

import { MotionProvider, Motion } from "twelve-principles/react";
export function Celebration() {
return (
<MotionProvider personality="playful">
<Motion as="section" enter="pop">
はじめてのプロジェクトを公開しました
</Motion>
</MotionProvider>
);
}

Appeal は単独の動きではなく、他の 11 原則の強さをまとめて決める層です。

  • Timingtempo が尺を決めます。
  • Exaggerationexaggeration が振れ幅を決めます。
  • Follow Throughbounce が戻りのオーバーシュートを決めます。
  • Anticipationanticipation が溜めの大きさを決めます。
  • Squash & Stretchsquash が変形の強さを決めます。
  • Solid Drawing — 浮上量と傾きの角度に exaggeration が掛かります。