コンテンツにスキップ

Solid Drawing(立体感のある作画)

平面の四角形でも、光源と遠近を一貫させれば「持ち上げられる物体」に見えます。本ライブラリは、画面の真上にある一つの光源と、本物の perspective() を前提に、浮上・影・傾きを揃えて描きます。

LIVE
ホバーで持ち上がり、ポインタに向かって傾く
カードにマウスを乗せると持ち上がって影が広がり、ポインタの位置に向かって傾く(最大 12deg)。外すとスプリングで平らに戻る。タッチ操作では反応しない。

Solid drawing は、描いたものに体積・重さ・奥行きを持たせ、三次元の空間に置かれているように描く原則です。キャラクターは紙の上の線ではなく、回転させれば裏側があり、床に立てば重心がある立体として扱います。

アニメーターは立体の理解を身につけるために、キャラクターを球・円柱・箱のような単純な形に分解して描いたり、正面・側面・背面を描いたターンアラウンドを用意したりします。どの角度から描いても形が崩れないことが、動かしたときの説得力になります。

もう一つの注意点が「ツイン(twinning)」です。左右の手足が鏡のように同じ姿勢をとると、平面的で硬く見えます。わずかに非対称にすることで、立体の中で重心を支えているように見えます。

光と影も立体感の手がかりです。光源が場面ごとに変わると、形がどれほど正確でも空間がばらばらに見えます。

UI の要素は平面ですが、ユーザーはカードやボタンを「画面の上に置かれた物」として読み取ります。立体感を一貫させるための規則は次の 3 つです。

  1. 光源は一つ、真上に固定する。影は常に真下へ落ち、x 方向にはずれません。要素ごとに影の向きが違うと、空間が一つにまとまりません。
  2. 高く浮いた要素ほど、影は遠く・大きく・薄くなるelevation がこの関係を一つの数値にまとめます。
  3. 傾きは本物の遠近で描くrotateX / rotateYperspective() なしで使うと、奥行きのない縮みにしか見えません。

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 の次のキーが立体表現に使われます。

キー 単位 描画
z px translate3d(x, y, z) の z。perspective がないと見た目は変わらない
rotateX deg rotateX()。上下の縁が手前・奥に倒れる
rotateY deg rotateY()。左右の縁が手前・奥に倒れる
elevation dp box-shadowshadowForElevation(elevation) で生成。0 未満は 0 にクランプ

transformperspective()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 } です。

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 既定 800

MotionSpecperspective を付け、各キーフレームの transform の先頭に perspective(px) を加えます。rotateX / rotateY / z を使うモーションに必須です。perspective が 0 以下なら RangeError を投げます。値が小さいほど遠近が強くなり、500 前後で誇張気味、1000 以上で穏やかになります。

function shadowForElevation(elevation: number): string;

elevation から 2 層の box-shadow 文字列を作ります。Pose.elevation を描画するときに内部で使われますが、静的な CSS にも使えます。負の値は 0 として扱います。

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 を呼ぶとリスナーを外し、ホバー状態を解除します。

function tiltable(element: HTMLElement, options?: TiltOptions): Cleanup;
オプション 既定値 説明
max number 8 最大回転角(deg)。max * personality.exaggerationtiltToward に渡る
perspective number 800 遠近の距離(px)
personality PersonalityInput "natural" 尺・角度・戻りのスプリング
reducedMotion ReducedMotion "auto" reduced motion の扱い

pointermove ごとに duration("fast", tempo)(150ms × tempo)の easings.out でポインタの方向へ傾き、ポインタが離れると settleSpring(personality) のスプリングで平らに戻ります。タッチでは反応しません。適用中は要素のすべてのレイヤー遷移に perspective が付き、Cleanup で外れます。

function useHover<T extends HTMLElement = HTMLElement>(options?: HoverOptions | boolean): RefCallback<T>;
function useTilt<T extends HTMLElement = HTMLElement>(options?: TiltOptions | boolean): RefCallback<T>;

false を渡すと無効になります。personalityreducedMotionMotionProvider の値が既定になり、オプションで個別に上書きできます。Motion コンポーネントでは hover / tilt プロパティに true またはオプションを渡します。

hover・tilt・press はそれぞれ別の「レイヤー」としてポーズを持ち、合成されます。移動・回転・elevation は加算、スケールと opacity は乗算です。そのため、ホバーで浮いたまま傾き、押すと沈む、という組み合わせが互いを上書きせずに動きます。

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());
}

フックで書く場合は、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>
);
}

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 { shadowForElevation } from "twelve-principles";
document.querySelector<HTMLElement>(".app-bar")!.style.boxShadow = shadowForElevation(4);
document.querySelector<HTMLElement>(".dialog")!.style.boxShadow = shadowForElevation(24);
  • Stagingstage は主役を elevation で持ち上げ、周囲を後退させます。光源が共通なので、ホバー中のカードと矛盾しません。
  • Squash & Stretchpressable の押し込みはスケールのレイヤーとして、ホバーの浮上と合成されます。
  • Follow Throughtiltable はポインタが離れると settleSpring で戻り、Personality の bounce 分だけ行き過ぎてから静止します。
  • Exaggeration — 浮上量と傾きの角度には Personality の exaggeration が掛かります。
  • Appeal — 光源と影の規則を一つに揃えることが、プロダクトとしての一貫性になります。