コンテンツにスキップ

React バインディング

twelve-principles/react は、コアの play / animateTo / 振る舞い / stage を React のライフサイクルに結び付ける薄い層です。react >= 18 は optional peer dependency なので、React を使わない場合はこのエントリを import しなければ依存は発生しません。

import { Motion, MotionProvider, Presence, useMotion } from "twelve-principles/react";
LIVE
「保存する」を押すとボタンが押し込まれ、トーストが pop で入退場する。Personality を切り替えて弾み方と尺を比べる。

このデモは次の 2 要素だけでできています。ボタンは <Motion as="button" press hover enter="pop">、トーストは <Presence show={open} enter="pop" exit="pop"> です。

エクスポート 種類 役割
MotionProvider / useMotionConfig コンテキスト Personality と reducedMotion をサブツリーに配る / 読む
SpecInput / resolveSpec 型 / 関数 spec か (personality) => spec を受け付ける入力形式
usePress / useHover / useTilt フック pressable / hoverable / tiltable を要素に付ける
useEnter フック マウント時に入場モーションを再生する
usePresence フック 退場モーションが終わるまでアンマウントを遅らせる
useMotion フック イベント駆動の命令的な制御(play / animateTo / stop
useStage フック active の間だけ Staging を適用する
useCascade フック コンテナの子要素を時間差で入場させる
Motion / Presence コンポーネント 上記フックを props で宣言的に使う
mergeRefs 関数 複数の ref を 1 つのコールバック ref にまとめる

エクスポートされる型は MotionConfigMotionProviderPropsSpecInputEnterInputCascadeOptionsMotionControlsPresenceStateusePresence の戻り値型)、PresenceOptionsMotionPropsPresenceProps です。

function MotionProvider(props: MotionProviderProps): JSX.Element;
function useMotionConfig(): MotionConfig;
interface MotionConfig {
personality: Personality;
reducedMotion: ReducedMotion;
}
prop 既定値 説明
personality PersonalityInput "natural" サブツリー全体の Personality。名前("natural" / "snappy" / "calm" / "playful" / "bouncy" / "cartoon")か Personality オブジェクト
reducedMotion ReducedMotion "auto" サブツリー全体の reduced motion の扱い
children ReactNode

プロバイダの外側では、コンテキストの既定値 { personality: personalities.natural, reducedMotion: "auto" } が使われます。つまりプロバイダなしでもすべてのフックは動作します。useMotionConfig() は解決済みの Personality オブジェクトを返すので、名前ではなく tempo などの値を直接読めます。

プロバイダはネストでき、内側が優先されます。たとえばアプリ全体は "natural"、オンボーディングだけ "playful"、祝福画面だけガードレールを外した "cartoon" にする、といった使い分けができます(派手な動き)。

SpecInput: spec か、Personality から spec を作る関数か

Section titled “SpecInput: spec か、Personality から spec を作る関数か”
type SpecInput = MotionSpec | ((personality: Personality) => MotionSpec);
function resolveSpec(input: SpecInput, personality: Personality): MotionSpec;

useMotion().play などは MotionSpec そのものに加えて、Personality を受け取って spec を返す関数も受け付けます。関数を渡すと、再生の時点で最も近い MotionProvider の Personality が渡されます。レシピに personality を配線するときの定番の書き方です。

play((p) => jump({ personality: p })); // プロバイダの個性で jump を組み立てる
play(squashStretch({ intensity: 0.08 })); // 個性に関係ない固定の spec

EnterInputTransitionKind | SpecInput です。useEnter / usePresence / useCascade / Motion / Presenceenter / exit に文字列を渡すと、enter(kind, { personality }) / exit(kind, { personality }) レシピが呼ばれます。

振る舞いフック: usePress / useHover / useTilt

Section titled “振る舞いフック: usePress / useHover / useTilt”
function usePress<T extends HTMLElement = HTMLElement>(options?: PressOptions | boolean): RefCallback<T>;
function useHover<T extends HTMLElement = HTMLElement>(options?: HoverOptions | boolean): RefCallback<T>;
function useTilt<T extends HTMLElement = HTMLElement>(options?: TiltOptions | boolean): RefCallback<T>;

それぞれ pressable / hoverable / tiltable を要素に付け、アンマウント時や無効化時にクリーンアップ(リスナー解除と静止状態への復帰)を行います。

引数 動作
省略 / true 有効。personalityreducedMotion はコンテキストの値
オプションオブジェクト 有効。コンテキストの値の上にオプションを重ねる(personality も上書き可)
false 無効。有効な状態から false に切り替えるとクリーンアップが走る

オプションは JSON.stringify した文字列で比較されるので、usePress({ depth: 0.04 }) のようにレンダーごとに新しいオブジェクトを書いても、値が同じなら付け直しは起きません。

フック 主なオプション(既定値) 詳細
usePress depthpressDepth(personality)。0.02〜0.05、guardrails: false なら最大 0.2) Squash & Stretch
useHover level(6)、pose Solid Drawing
useTilt max(8)、perspective(800) Solid Drawing

3 つとも要素上の別々のレイヤー("press" / "hover" / "tilt")を使うので、同じ要素に重ねても互いを上書きしません。1 つの要素に複数付けるときは mergeRefs でまとめます。

import { useMemo } from "react";
import { mergeRefs, useHover, usePress, useTilt } from "twelve-principles/react";
export function ProductCard({ disabled }: { disabled: boolean }) {
const press = usePress<HTMLButtonElement>(!disabled);
const hover = useHover<HTMLButtonElement>({ level: 8, pose: { scale: 1.02 } });
const tilt = useTilt<HTMLButtonElement>({ max: 6 });
const ref = useMemo(() => mergeRefs(press, hover, tilt), [press, hover, tilt]);
return (
<button ref={ref} disabled={disabled}>
Product
</button>
);
}
function useEnter<T extends HTMLElement = HTMLElement>(input?: EnterInput | false): RefCallback<T>; // 既定 "rise"

要素がマウントされた瞬間に入場モーションを再生します。ブラウザではレイアウトエフェクトで再生するので、最初のペイントより前に隠れたポーズが適用され、完成形が一瞬見えてしまう「ちらつき」が起きません。

再生は マウントされた要素ごとに 1 回だけ です。マウント後に input を変えても再生し直しません。同じ要素で入場をやり直したい場合は key を変えて再マウントするか、useMotionplay を使います。false を渡すと何もしません。

function usePresence<T extends HTMLElement = HTMLElement>(
show: boolean,
options?: PresenceOptions,
): PresenceState<T>;
interface PresenceOptions {
enter?: EnterInput | false;
exit?: EnterInput | false;
initial?: boolean;
}
// 型名 PresenceState としてエクスポートされる
interface Presence<T extends HTMLElement> {
present: boolean;
ref: RefCallback<T>;
}
オプション 既定値 説明
enter EnterInput | false "rise" 表示時の入場。false で入場なし
exit EnterInput | false "fade" 非表示時の退場。false なら即座にアンマウント
initial boolean true 最初のレンダーで showtrue のときにも入場を再生するか

戻り値の presenttrue の間だけ要素をレンダーし、ref を付けます。

  1. showtrue になると present も即座に true になり、要素がマウントされて入場が再生されます。
  2. showfalse になっても presenttrue のままで、退場モーションが再生されます。finished が解決してはじめて presentfalse になり、要素がアンマウントされます。
  3. 退場の途中で show が再び true になった場合は、入場を最初からやり直しません。退場を中断し、animateTo(element, {})現在のポーズから静止状態へ 戻します(尺は durations.base × tempoeasings.out)。見た目が途切れずに反転します。

enter / exit / initial はエフェクトの実行時に最新値が読まれます。エフェクトは show と要素の変化でだけ走るので、オプションを変えただけではモーションは再生されません。

import { usePresence } from "twelve-principles/react";
export function Toast({ open, message }: { open: boolean; message: string }) {
const { present, ref } = usePresence<HTMLDivElement>(open, { enter: "pop", exit: "pop" });
if (!present) return null;
return (
<div ref={ref} role="status" className="toast">
{message}
</div>
);
}
function useMotion<T extends HTMLElement = HTMLElement>(): MotionControls<T>;
interface MotionControls<T extends HTMLElement> {
ref: RefObject<T>;
play(input: SpecInput, options?: PlayOptions): Animation | undefined;
animateTo(pose: Pose, options?: AnimateToOptions): Animation | undefined;
stop(): void;
}

クリックや入力検証などのイベントからモーションを起こすための命令的な API です。

  • refuseRef のオブジェクト ref です。要素の ref にそのまま渡します。
  • play(input, options)SpecInput を解決して play() を呼びます。options.reducedMotion を省略するとコンテキストの値が使われます。要素がまだ付いていなければ undefined を返します。
  • animateTo(pose, options) は現在のポーズから pose への中断可能な遷移です(API リファレンス)。
  • stop() は再生中のモーションをその場で止めます。

playanimateTouseCallback でメモ化されており、Personality と reducedMotion が変わったときだけ作り直されます。

import { jump, shake } from "twelve-principles";
import { useMotion } from "twelve-principles/react";
export function CodeInput({ onSubmit }: { onSubmit: (code: string) => boolean }) {
const field = useMotion<HTMLInputElement>();
return (
<form
onSubmit={(e) => {
e.preventDefault();
const ok = onSubmit(field.ref.current?.value ?? "");
field.play((p) => (ok ? jump({ personality: p, height: 8 }) : shake({ personality: p })));
}}
>
<input ref={field.ref} aria-label="確認コード" />
</form>
);
}
function useStage<T extends HTMLElement = HTMLElement>(
active: boolean,
options?: Omit<StageOptions, "personality" | "reducedMotion">,
): RefCallback<T>;

activetrue の間、要素を主役にして周囲(既定は兄弟要素)を暗く・ぼかし・後退させます。false になるかアンマウントされると release() が呼ばれて元に戻ります。オプションは dim(0.5)、blur(2)、recede(0.02)、lift(12)、surroundings です(Staging)。

surroundings はステージングの開始時に一度だけ読まれ、比較対象から除外されています。surroundings={[a, b]} のようにインライン配列を書いても、毎レンダーでステージングがやり直されることはありません。その他のオプション、Personality、reducedMotion が変わると、いったん解除してからやり直します。

import { useState } from "react";
import { useStage } from "twelve-principles/react";
function Tile({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
const ref = useStage<HTMLButtonElement>(active, { dim: 0.6 });
return (
<button ref={ref} onClick={onClick} aria-pressed={active}>
{label}
</button>
);
}
export function Tiles() {
const [active, setActive] = useState<number | null>(null);
return (
<div className="tiles">
{["A", "B", "C"].map((label, i) => (
<Tile key={label} label={label} active={active === i} onClick={() => setActive(active === i ? null : i)} />
))}
</div>
);
}
function useCascade<T extends HTMLElement = HTMLElement>(
input?: EnterInput, // 既定 "rise"
options?: CascadeOptions,
): RefCallback<T>;
interface CascadeOptions extends OverlapOptions {
trigger?: unknown;
}
オプション 既定値 説明
each number 30 隣同士の遅延(ms)。cap でクランプ
cap number 50MAX_STAGGER_MS 1 項目あたりの遅延の上限(ms)。派手なカスケードでは上げる。負なら RangeError
total number 300 カスケード全体の上限(ms)
from "first" | "last" | "center" | number "first" どこから始めるか
drag number 0.1 起点から n ステップ後ろの子に bounce = drag × n(上限 0.8)の follow through を付ける
trigger unknown この値が変わるたびにカスケードを再生し直す

ref を付けたコンテナの その時点の子要素HTMLElement のみ)に、overlap(spec, children.length, options) の結果を 1 つずつ再生します。再生されるのは、コンテナ要素が付いたときと trigger が変わったときだけです。子の数やオプション(cap など)が変わっただけでは再生されないので、項目の追加に合わせて再生したいときは trigger={items.length} のように、オプションの切り替えで再生したいときはその値を trigger に含めて渡します。

import { enter } from "twelve-principles";
import { useCascade } from "twelve-principles/react";
export function Results({ items, query }: { items: string[]; query: string }) {
const ref = useCascade<HTMLUListElement>((p) => enter("slideRight", { personality: p, distance: 24 }), {
each: 40,
trigger: query,
});
return (
<ul ref={ref}>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
const Motion: ForwardRefExoticComponent<MotionProps & RefAttributes<HTMLElement>>;
prop 既定値 説明
as keyof JSX.IntrinsicElements "div" レンダーする要素
press boolean | PressOptions false usePress と同じ
hover boolean | HoverOptions false useHover と同じ
tilt boolean | TiltOptions false useTilt と同じ
enter EnterInput | false false useEnter と同じ。マウント時に 1 回だけ再生

それ以外の props(classNameonClickaria-* など HTML 属性)はそのまま要素に渡ります。forwardRef 対応なので、外から渡した ref も内部の ref とまとめて付きます。

function Presence(props: PresenceProps): JSX.Element | null;
interface PresenceProps extends PresenceOptions, Omit<AllHTMLAttributes<HTMLElement>, "as"> {
show: boolean;
as?: keyof JSX.IntrinsicElements; // 既定 "div"
children?: ReactNode;
}

usePresence のコンポーネント版です。enter(既定 "rise")、exit(既定 "fade")、initial(既定 true)の意味は usePresence と同じで、presentfalse になると null を返します。

import { enter, exit, hoverable, play, pressable } from "twelve-principles";
const save = document.querySelector<HTMLButtonElement>("#save")!;
const toast = document.querySelector<HTMLElement>("#toast")!;
const opts = { personality: "playful" } as const;
pressable(save, opts);
hoverable(save, opts);
play(save, enter("pop", opts));
let open = false;
save.addEventListener("click", async () => {
open = !open;
if (open) {
toast.hidden = false;
play(toast, enter("pop", opts));
} else {
await play(toast, exit("pop", opts)).finished.catch(() => undefined);
if (!open) toast.hidden = true;
}
});

Vanilla 版は退場中に再表示されると入場を最初から再生します。React 版の Presence は現在のポーズから静止状態へ戻すので、連打しても途切れません。

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T>;

コールバック ref とオブジェクト ref を混在させて 1 つのコールバック ref にまとめます。undefined は無視されます。レンダーのたびに新しい関数を返すので、頻繁に再レンダーされるコンポーネントでは useMemo で包むと ref の付け外しを避けられます(<Motion> は内部でそうしています)。

import { useMemo } from "react";
import { mergeRefs, useMotion, usePress } from "twelve-principles/react";
export function LikeButton() {
const motion = useMotion<HTMLButtonElement>();
const press = usePress<HTMLButtonElement>();
const ref = useMemo(() => mergeRefs(motion.ref, press), [motion.ref, press]);
return <button ref={ref}>Like</button>;
}

useMotion 以外のフック(usePress / useHover / useTilt / useEnter / usePresence / useStage / useCascade)はコールバック ref を返します。中身は useState のセッターで、要素が付くと state が更新され、要素を依存に持つレイアウトエフェクトが走ります。

  • 条件付きレンダーで要素が後から現れても、差し替わっても、そのたびに正しく付け直されます。オブジェクト ref では要素の変化を検知できないため、この形になっています。
  • 要素が付いた直後に 1 回だけ追加の再レンダーが発生します。
  • 戻り値の関数は安定しているので、mergeRefs の依存配列に入れても再計算は起きません。

内部では typeof window === "undefined" のときに useEffect、ブラウザでは useLayoutEffect を使います。サーバーではどちらのエフェクトも実行されないので、サーバーレンダリング時にモーション関連のコードは走らず、useLayoutEffect の SSR 警告も出ません。ブラウザでレイアウトエフェクトを使うのは、入場の開始ポーズを最初のペイントより前に適用するためです。