React バインディング
twelve-principles/react は、コアの play / animateTo / 振る舞い / stage を React のライフサイクルに結び付ける薄い層です。react >= 18 は optional peer dependency なので、React を使わない場合はこのエントリを import しなければ依存は発生しません。
import { Motion, MotionProvider, Presence, useMotion } from "twelve-principles/react";このデモは次の 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 にまとめる |
エクスポートされる型は MotionConfig、MotionProviderProps、SpecInput、EnterInput、CascadeOptions、MotionControls、PresenceState(usePresence の戻り値型)、PresenceOptions、MotionProps、PresenceProps です。
MotionProvider と useMotionConfig
Section titled “MotionProvider と useMotionConfig”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 })); // 個性に関係ない固定の specEnterInput は TransitionKind | SpecInput です。useEnter / usePresence / useCascade / Motion / Presence の enter / 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 |
有効。personality と reducedMotion はコンテキストの値 |
| オプションオブジェクト | 有効。コンテキストの値の上にオプションを重ねる(personality も上書き可) |
false |
無効。有効な状態から false に切り替えるとクリーンアップが走る |
オプションは JSON.stringify した文字列で比較されるので、usePress({ depth: 0.04 }) のようにレンダーごとに新しいオブジェクトを書いても、値が同じなら付け直しは起きません。
| フック | 主なオプション(既定値) | 詳細 |
|---|---|---|
usePress |
depth(pressDepth(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> );}useEnter
Section titled “useEnter”function useEnter<T extends HTMLElement = HTMLElement>(input?: EnterInput | false): RefCallback<T>; // 既定 "rise"要素がマウントされた瞬間に入場モーションを再生します。ブラウザではレイアウトエフェクトで再生するので、最初のペイントより前に隠れたポーズが適用され、完成形が一瞬見えてしまう「ちらつき」が起きません。
再生は マウントされた要素ごとに 1 回だけ です。マウント後に input を変えても再生し直しません。同じ要素で入場をやり直したい場合は key を変えて再マウントするか、useMotion の play を使います。false を渡すと何もしません。
usePresence
Section titled “usePresence”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 |
最初のレンダーで show が true のときにも入場を再生するか |
戻り値の present が true の間だけ要素をレンダーし、ref を付けます。
showがtrueになるとpresentも即座にtrueになり、要素がマウントされて入場が再生されます。showがfalseになってもpresentはtrueのままで、退場モーションが再生されます。finishedが解決してはじめてpresentがfalseになり、要素がアンマウントされます。- 退場の途中で
showが再びtrueになった場合は、入場を最初からやり直しません。退場を中断し、animateTo(element, {})で 現在のポーズから静止状態へ 戻します(尺はdurations.base×tempo、easings.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> );}useMotion
Section titled “useMotion”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 です。
refはuseRefのオブジェクト ref です。要素のrefにそのまま渡します。play(input, options)はSpecInputを解決してplay()を呼びます。options.reducedMotionを省略するとコンテキストの値が使われます。要素がまだ付いていなければundefinedを返します。animateTo(pose, options)は現在のポーズからposeへの中断可能な遷移です(API リファレンス)。stop()は再生中のモーションをその場で止めます。
play と animateTo は useCallback でメモ化されており、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> );}useStage
Section titled “useStage”function useStage<T extends HTMLElement = HTMLElement>( active: boolean, options?: Omit<StageOptions, "personality" | "reducedMotion">,): RefCallback<T>;active が true の間、要素を主役にして周囲(既定は兄弟要素)を暗く・ぼかし・後退させます。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> );}useCascade
Section titled “useCascade”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 |
50(MAX_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> );}<Motion>
Section titled “<Motion>”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(className、onClick、aria-* など HTML 属性)はそのまま要素に渡ります。forwardRef 対応なので、外から渡した ref も内部の ref とまとめて付きます。
<Presence>
Section titled “<Presence>”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 と同じで、present が false になると 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; }});import { useState } from "react";import { Motion, MotionProvider, Presence } from "twelve-principles/react";
export function SaveButton() { const [open, setOpen] = useState(false); return ( <MotionProvider personality="playful"> <Motion as="button" press hover enter="pop" onClick={() => setOpen((o) => !o)}> {open ? "閉じる" : "保存する"} </Motion> <Presence show={open} enter="pop" exit="pop" role="status" className="toast"> 保存しました </Presence> </MotionProvider> );}Vanilla 版は退場中に再表示されると入場を最初から再生します。React 版の Presence は現在のポーズから静止状態へ戻すので、連打しても途切れません。
mergeRefs
Section titled “mergeRefs”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>;}コールバック ref パターン
Section titled “コールバック ref パターン”useMotion 以外のフック(usePress / useHover / useTilt / useEnter / usePresence / useStage / useCascade)はコールバック ref を返します。中身は useState のセッターで、要素が付くと state が更新され、要素を依存に持つレイアウトエフェクトが走ります。
- 条件付きレンダーで要素が後から現れても、差し替わっても、そのたびに正しく付け直されます。オブジェクト ref では要素の変化を検知できないため、この形になっています。
- 要素が付いた直後に 1 回だけ追加の再レンダーが発生します。
- 戻り値の関数は安定しているので、
mergeRefsの依存配列に入れても再計算は起きません。
内部では typeof window === "undefined" のときに useEffect、ブラウザでは useLayoutEffect を使います。サーバーではどちらのエフェクトも実行されないので、サーバーレンダリング時にモーション関連のコードは走らず、useLayoutEffect の SSR 警告も出ません。ブラウザでレイアウトエフェクトを使うのは、入場の開始ポーズを最初のペイントより前に適用するためです。