Solid Drawing(立体感のある作画)
平面の四角形でも、光源と遠近を一貫させれば「持ち上げられる物体」に見えます。本ライブラリは、画面の真上にある一つの光源と、本物の perspective() を前提に、浮上・影・傾きを揃えて描きます。
アニメーションにおける原則
Section titled “アニメーションにおける原則”Solid drawing は、描いたものに体積・重さ・奥行きを持たせ、三次元の空間に置かれているように描く原則です。キャラクターは紙の上の線ではなく、回転させれば裏側があり、床に立てば重心がある立体として扱います。
アニメーターは立体の理解を身につけるために、キャラクターを球・円柱・箱のような単純な形に分解して描いたり、正面・側面・背面を描いたターンアラウンドを用意したりします。どの角度から描いても形が崩れないことが、動かしたときの説得力になります。
もう一つの注意点が「ツイン(twinning)」です。左右の手足が鏡のように同じ姿勢をとると、平面的で硬く見えます。わずかに非対称にすることで、立体の中で重心を支えているように見えます。
光と影も立体感の手がかりです。光源が場面ごとに変わると、形がどれほど正確でも空間がばらばらに見えます。
UI への翻訳
Section titled “UI への翻訳”UI の要素は平面ですが、ユーザーはカードやボタンを「画面の上に置かれた物」として読み取ります。立体感を一貫させるための規則は次の 3 つです。
- 光源は一つ、真上に固定する。影は常に真下へ落ち、x 方向にはずれません。要素ごとに影の向きが違うと、空間が一つにまとまりません。
- 高く浮いた要素ほど、影は遠く・大きく・薄くなる。
elevationがこの関係を一つの数値にまとめます。 - 傾きは本物の遠近で描く。
rotateX/rotateYをperspective()なしで使うと、奥行きのない縮みにしか見えません。
elevation は抽象的な「dp」単位で、0 が面に接した状態、24 前後がかなり高く浮いた状態です。影は 2 層で構成され、shadowForElevation が次の文字列を作ります。
elevation |
生成される box-shadow |
|---|---|
| 0 | 0px 0px 0px rgba(0, 0, 0, 0.000), 0px 0px 0px rgba(0, 0, 0, 0.000) |
| 1 | 0px 0.5px 1px rgba(0, 0, 0, 0.117), 0px 0.15px 2.5px rgba(0, 0, 0, 0.050) |
| 4 | 0px 2px 4px rgba(0, 0, 0, 0.220), 0px 0.6px 10px rgba(0, 0, 0, 0.100) |
| 10 | 0px 5px 10px rgba(0, 0, 0, 0.190), 0px 1.5px 25px rgba(0, 0, 0, 0.100) |
| 24 | 0px 12px 24px rgba(0, 0, 0, 0.120), 0px 3.6px 60px rgba(0, 0, 0, 0.100) |
- 1 層目(キーシャドウ)は y オフセット
e × 0.5、ぼかしe。不透明度は 0.24 から始まり、eが 1 増えるごとに 0.005 下がって、eが 24 以上では 0.12 で止まります。高く浮くほど影が薄くなる部分です。 - 2 層目(環境光の影)は y オフセット
e × 0.15、ぼかしe × 2.5、不透明度 0.1 です。 eが 0〜2 の間は両方の層がまとめてフェードインするため、平らな状態から浮き上がるときに影が突然現れません。- 常に 2 層の同じ構造なので、どの
elevation同士でも滑らかに補間できます。
浮上量の目安は、ホバーで 4〜8、ドラッグ中やモーダルで 12〜24 です。hoverable の既定は 6 です。
Pose の立体キー
Section titled “Pose の立体キー”Pose の次のキーが立体表現に使われます。
| キー | 単位 | 描画 |
|---|---|---|
z |
px | translate3d(x, y, z) の z。perspective がないと見た目は変わらない |
rotateX |
deg | rotateX()。上下の縁が手前・奥に倒れる |
rotateY |
deg | rotateY()。左右の縁が手前・奥に倒れる |
elevation |
dp | box-shadow を shadowForElevation(elevation) で生成。0 未満は 0 にクランプ |
transform は perspective()、translate3d()、rotateX()、rotateY()、rotate()、skew()、scale() の順に組み立てられます。
function lift(level?: number): Pose; // 既定 4// 返り値: { y: -level * 0.5, elevation: level }要素を手前に持ち上げるポーズです。elevation と同時に y を level の半分だけ上げるので、影が広がると同時に要素そのものも浮きます。lift() は { y: -2, elevation: 4 }、lift(10) は { y: -5, elevation: 10 } です。
tiltToward
Section titled “tiltToward”function tiltToward(point: Point, maxDeg?: number): Pose; // maxDeg 既定 8// 返り値: { rotateX: -y * 2 * maxDeg, rotateY: x * 2 * maxDeg }面を point の方向へ向ける回転です。point は要素に対して正規化した座標で、左上が (-0.5, -0.5)、中心が (0, 0)、右下が (0.5, 0.5) です。範囲外の値は ±0.5 にクランプされるので、端で最大 maxDeg になります。
tiltToward({ x: 0.5, y: -0.5 }); // { rotateX: 8, rotateY: 8 } 右上の角tiltToward({ x: 0.25, y: 0.1 }, 12); // { rotateX: -2.4, rotateY: 6 }function solid(spec: MotionSpec, perspective?: number): MotionSpec; // perspective 既定 800MotionSpec に perspective を付け、各キーフレームの transform の先頭に perspective(px) を加えます。rotateX / rotateY / z を使うモーションに必須です。perspective が 0 以下なら RangeError を投げます。値が小さいほど遠近が強くなり、500 前後で誇張気味、1000 以上で穏やかになります。
shadowForElevation
Section titled “shadowForElevation”function shadowForElevation(elevation: number): string;elevation から 2 層の box-shadow 文字列を作ります。Pose.elevation を描画するときに内部で使われますが、静的な CSS にも使えます。負の値は 0 として扱います。
hoverable
Section titled “hoverable”function hoverable(element: HTMLElement, options?: HoverOptions): Cleanup;| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
level |
number |
6 |
ホバー時の浮上量。lift(level * personality.exaggeration) として使う |
pose |
Pose |
なし | ホバー状態に追加するポーズ。lift の結果の上に展開される(例 { scale: 1.02 }) |
personality |
PersonalityInput |
"natural" |
尺と浮上量の倍率 |
reducedMotion |
ReducedMotion |
"auto" |
reduced motion の扱い |
浮上は duration("base", tempo)(200ms × tempo)の easings.out、戻りは duration("slow", tempo)(300ms × tempo)の easings.inOut です。マウスとペンだけに反応し、pointerType が "touch" のときは何もしません。返り値の Cleanup を呼ぶとリスナーを外し、ホバー状態を解除します。
tiltable
Section titled “tiltable”function tiltable(element: HTMLElement, options?: TiltOptions): Cleanup;| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
max |
number |
8 |
最大回転角(deg)。max * personality.exaggeration が tiltToward に渡る |
perspective |
number |
800 |
遠近の距離(px) |
personality |
PersonalityInput |
"natural" |
尺・角度・戻りのスプリング |
reducedMotion |
ReducedMotion |
"auto" |
reduced motion の扱い |
pointermove ごとに duration("fast", tempo)(150ms × tempo)の easings.out でポインタの方向へ傾き、ポインタが離れると settleSpring(personality) のスプリングで平らに戻ります。タッチでは反応しません。適用中は要素のすべてのレイヤー遷移に perspective が付き、Cleanup で外れます。
React: useHover / useTilt / Motion
Section titled “React: useHover / useTilt / Motion”function useHover<T extends HTMLElement = HTMLElement>(options?: HoverOptions | boolean): RefCallback<T>;function useTilt<T extends HTMLElement = HTMLElement>(options?: TiltOptions | boolean): RefCallback<T>;false を渡すと無効になります。personality と reducedMotion は MotionProvider の値が既定になり、オプションで個別に上書きできます。Motion コンポーネントでは hover / tilt プロパティに true またはオプションを渡します。
hover・tilt・press はそれぞれ別の「レイヤー」としてポーズを持ち、合成されます。移動・回転・elevation は加算、スケールと opacity は乗算です。そのため、ホバーで浮いたまま傾き、押すと沈む、という組み合わせが互いを上書きせずに動きます。
商品カードのホバーとチルト
Section titled “商品カードのホバーとチルト”import { hoverable, tiltable, type Cleanup } from "twelve-principles";
export function enhanceCards(root: HTMLElement): Cleanup { const cleanups = Array.from(root.querySelectorAll<HTMLElement>(".product-card")).flatMap((card) => [ hoverable(card, { level: 8, pose: { scale: 1.02 } }), tiltable(card, { max: 6 }), ]); return () => cleanups.forEach((cleanup) => cleanup());}import type { ReactNode } from "react";import { Motion } from "twelve-principles/react";
export function ProductCard({ children }: { children: ReactNode }) { return ( <Motion as="article" className="product-card" hover={{ level: 8, pose: { scale: 1.02 } }} tilt={{ max: 6 }}> {children} </Motion> );}フックで書く場合は、mergeRefs で ref をまとめます。mergeRefs は呼ぶたびに新しい関数を返すので、useMemo で固定してください。固定しないと、再レンダーのたびに振る舞いが付け直されます。
import { useMemo, type ReactNode } from "react";import { mergeRefs, useHover, useTilt } from "twelve-principles/react";
export function ProductCard({ children }: { children: ReactNode }) { const hoverRef = useHover<HTMLElement>({ level: 8 }); const tiltRef = useTilt<HTMLElement>({ max: 6 }); const ref = useMemo(() => mergeRefs(hoverRef, tiltRef), [hoverRef, tiltRef]); return ( <article ref={ref} className="product-card"> {children} </article> );}カードを裏返す
Section titled “カードを裏返す”rotateY を 180deg 回すフリップには solid で遠近を付けます。裏面を隠すための backface-visibility: hidden は CSS 側で指定します。
import { play, poseToPose, solid } from "twelve-principles";
let flipped = false;
export function flip(card: HTMLElement): void { flipped = !flipped; const from = flipped ? 0 : 180; play(card, solid(poseToPose([{ rotateY: from }, { rotateY: 180 - from }], { duration: 400 }), 1000));}import { useState, type ReactNode } from "react";import { poseToPose, solid } from "twelve-principles";import { useMotion } from "twelve-principles/react";
export function FlipCard({ children }: { children: ReactNode }) { const { ref, play } = useMotion<HTMLButtonElement>(); const [flipped, setFlipped] = useState(false);
const toggle = () => { const from = flipped ? 180 : 0; play(solid(poseToPose([{ rotateY: from }, { rotateY: 180 - from }], { duration: 400 }), 1000)); setFlipped(!flipped); };
return ( <button ref={ref} type="button" className="flip-card" aria-pressed={flipped} onClick={toggle}> {children} </button> );}静的な要素にも同じ影を使う
Section titled “静的な要素にも同じ影を使う”動かない要素の影も同じ関数で作れば、アニメーション中の要素と光源が揃います。
import { shadowForElevation } from "twelve-principles";
document.querySelector<HTMLElement>(".app-bar")!.style.boxShadow = shadowForElevation(4);document.querySelector<HTMLElement>(".dialog")!.style.boxShadow = shadowForElevation(24);ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”- Staging —
stageは主役をelevationで持ち上げ、周囲を後退させます。光源が共通なので、ホバー中のカードと矛盾しません。 - Squash & Stretch —
pressableの押し込みはスケールのレイヤーとして、ホバーの浮上と合成されます。 - Follow Through —
tiltableはポインタが離れるとsettleSpringで戻り、Personality のbounce分だけ行き過ぎてから静止します。 - Exaggeration — 浮上量と傾きの角度には Personality の
exaggerationが掛かります。 - Appeal — 光源と影の規則を一つに揃えることが、プロダクトとしての一貫性になります。