Staging(演出)
画面の中で今いちばん大事なものを一つに決め、それ以外を一歩下がらせることで、視線は迷わず主役に向かいます。twelve-principles では、主役と周囲のポーズを返す stagingPoses と、それを DOM に適用する stage / useStage を提供しています。
アニメーションにおける原則
Section titled “アニメーションにおける原則”Staging は、演劇や映画の「演出」と同じ考え方です。観客が一度に理解できることは一つだけなので、アイデア、感情、動作のどれを伝えるにしても、それが誤解なく見えるように画面を組み立てます。
手段は構図、カメラの位置、光と影、そして動きです。主役を画面の重心に置き、背景は暗く・単純にし、同じ瞬間に他のものを大きく動かさない。シルエットだけで動作が読めるように、ポーズを横向きに見せることもあります。逆に、背景で別のキャラクターが派手に動いていれば、どれほど良い演技でも見逃されます。
UI への翻訳
Section titled “UI への翻訳”UI での Staging は、「今ユーザーが注目すべき要素」を視覚的に前景へ出すことです。選択中のカード、編集中の行、オンボーディングで説明中のボタンなどが該当します。stagingPoses の既定値は次のとおりです。
| 対象 | 変化 | 既定値 |
|---|---|---|
| 主役(focus) | わずかに拡大、影で浮かせる | scale 1.02、elevation 12 |
| 周囲(surroundings) | 薄く、ぼかし、わずかに縮小 | opacity 0.5、blur 2px、scale 0.98 |
主役を 2% 大きく、周囲を 2% 小さくするだけでも、奥行きの差が 4% 生まれ、「手前と奥」として読まれます。適用には durations.slow(300ms)× tempo の ease-out、解除には durations.base(200ms)× tempo の ease-in-out を使います。入るときはゆっくり落ち着き、戻るときは素早く、という非対称です。
向いている場面:
- 複数の選択肢から一つを選んでいる状態(料金プラン、配送方法)。
- オンボーディングやツアーで特定の UI を説明しているとき。
- インライン編集中の行やカード。
避けるべき場面:
- 常時表示のナビゲーションや、頻繁に切り替わるタブ。周囲が毎回暗くなるのは煩わしく、読みにくくなります。
- 周囲の情報を読み比べる必要があるとき。opacity 0.5 ではテキストのコントラストが大きく下がります。
stagingPoses
Section titled “stagingPoses”function stagingPoses(options?: StagingOptions): StagingPoses;
interface StagingPoses { focus: Pose; surroundings: Pose;}主役と周囲のポーズだけを返す純関数です。DOM には触れません。戻り値は focus: { scale: 1 + recede, elevation: lift }、surroundings: { opacity: 1 - dim, blur, scale: 1 - recede } です。
| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
dim |
number |
0.5 |
周囲を薄くする量(0〜1)。範囲外は RangeError。 |
blur |
number |
2 |
周囲のぼかし(px)。 |
recede |
number |
0.02 |
周囲を縮める割合。主役は同じ割合だけ拡大する。 |
lift |
number |
12 |
主役の elevation(影の高さ)。 |
function stage(focus: HTMLElement, options?: StageOptions): StageHandle;
interface StageHandle { release(): Promise<void>;}focus を前へ出し、周囲を下げます。StageOptions は StagingOptions のすべて(dim / blur / recede / lift、既定値は上の表と同じ)に加えて、次を受け取ります。
| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
surroundings |
readonly HTMLElement[] |
focus の兄弟要素 |
一歩下がらせる要素。既定は親要素の子のうち focus 以外の HTMLElement。 |
personality |
PersonalityInput |
"natural" |
個性。tempo が適用・解除の尺に掛かる。 |
reducedMotion |
ReducedMotion |
"auto" |
reduced motion の扱い。 |
動作の詳細:
- 主役は拡大すると隣の要素に重なるため、描画順を前に出します。計算済みの
positionがstaticならインラインでposition: relativeを付け、インラインのz-indexを10にします。 - 変化は名前付きレイヤー
"stage"に設定されます(setLayer)。同じ要素の"hover"/"press"/"tilt"レイヤーとは合成され、互いを上書きしません。たとえばhoverableの浮き上がり(既定 elevation 6)と重なると、主役の elevation は 18 になります。 - 適用は
duration("slow", tempo)とeasings.out、解除はduration("base", tempo)とeasings.inOutです。 release()は"stage"レイヤーを外して元の状態へ戻し、そのモーションが終わってからz-indexとpositionをstage()呼び出し前のインライン値に戻します。戻る途中も主役は前面に残ります。release()は何度呼んでも同じ Promise を返します。
useStage
Section titled “useStage”function useStage<T extends HTMLElement = HTMLElement>( active: boolean, options?: Omit<StageOptions, "personality" | "reducedMotion">,): RefCallback<T>;active が true の間だけ、ref を付けた要素をステージングする React フックです。personality と reducedMotion は MotionProvider から受け取ります。
activeがfalseになるか、アンマウントされるとrelease()します。dim/blur/recede/liftは値で比較されるので、インラインのオブジェクトリテラルを渡しても毎レンダーで再ステージされません。値が変わったときだけ解除して掛け直します。surroundingsは ステージングの開始時に一度だけ読まれます。インラインで配列を作って渡しても再ステージは起きませんが、ステージング中に配列の中身を変えても反映されません。
カードのグリッドで、クリックしたカードを主役にします。同じカードをもう一度押すと解除、別のカードを押すと切り替えます。
import { stage, type StageHandle } from "twelve-principles";
const grid = document.querySelector<HTMLElement>(".plan-grid")!;let staged: { card: HTMLElement; handle: StageHandle } | undefined;
grid.addEventListener("click", (event) => { const card = (event.target as HTMLElement).closest<HTMLElement>(".plan-card"); if (!card) return;
const previous = staged; staged = undefined; previous?.handle.release(); previous?.card.setAttribute("aria-pressed", "false");
if (previous?.card !== card) { staged = { card, handle: stage(card, { dim: 0.4 }) }; card.setAttribute("aria-pressed", "true"); }});兄弟要素以外を下げたいときは surroundings を明示します。ツアーで特定のボタンを説明する例です。ぼかしは要素数が多いと描画コストが高いので切っています。
import { stage } from "twelve-principles";
export async function explain(target: HTMLElement, done: Promise<void>) { const panels = Array.from(document.querySelectorAll<HTMLElement>(".panel")); const handle = stage(target, { surroundings: panels.filter((panel) => !panel.contains(target)), dim: 0.7, blur: 0, }); await done; // ユーザーが「次へ」を押すまで待つ await handle.release();}stage の動きではなくポーズだけが欲しいときは stagingPoses を使い、自分でレイヤーに設定します。
import { setLayer, stagingPoses } from "twelve-principles";
const { focus, surroundings } = stagingPoses({ dim: 0.8, blur: 0, recede: 0 });const sidebar = document.querySelector<HTMLElement>(".sidebar")!;const comments = document.querySelector<HTMLElement>(".comments")!;
export function setTheaterMode(player: HTMLElement, on: boolean) { setLayer(player, "theater", on ? focus : null); for (const el of [sidebar, comments]) setLayer(el, "theater", on ? surroundings : null);}料金プランの選択。各カードが自分の selected を useStage に渡すだけで、兄弟のカードが自動的に下がります。
import { useState } from "react";import { useStage } from "twelve-principles/react";
type Plan = { id: string; name: string; price: string };
function PlanCard({ plan, selected, onSelect }: { plan: Plan; selected: boolean; onSelect: () => void }) { const ref = useStage<HTMLButtonElement>(selected, { dim: 0.4 }); return ( <button ref={ref} type="button" className="plan-card" aria-pressed={selected} onClick={onSelect}> <strong>{plan.name}</strong> <span>{plan.price}</span> </button> );}
export function PlanPicker({ plans }: { plans: Plan[] }) { const [selected, setSelected] = useState<string | null>(null); return ( <div className="plan-grid"> {plans.map((plan) => ( <PlanCard key={plan.id} plan={plan} selected={selected === plan.id} onSelect={() => setSelected(selected === plan.id ? null : plan.id)} /> ))} </div> );}兄弟以外を下げる場合は surroundings を渡します。配列はステージングの開始時に読まれるので、開始のきっかけになるイベントハンドラで集めておけば十分です。
import { useState, type ReactNode } from "react";import { useStage } from "twelve-principles/react";
function Spotlight(props: { active: boolean; surroundings: HTMLElement[]; children: ReactNode }) { const ref = useStage<HTMLDivElement>(props.active, { surroundings: props.surroundings, dim: 0.7, blur: 0 }); return ( <div ref={ref} className="spotlight"> {props.children} </div> );}
export function Dashboard() { const [touring, setTouring] = useState(false); const [panels, setPanels] = useState<HTMLElement[]>([]);
const toggleTour = () => { setPanels(Array.from(document.querySelectorAll<HTMLElement>(".panel"))); setTouring((t) => !t); };
return ( <main> <Spotlight active={touring} surroundings={panels}> <button type="button" onClick={toggleTour}> {touring ? "ツアーを終える" : "この画面について"} </button> </Spotlight> <section className="panel">売上</section> <section className="panel">在庫</section> </main> );}ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”- Solid Drawing — 主役の浮き上がりは
elevationによる影で描かれます。影の大きさ・柔らかさは他の浮いた要素と同じ光源で統一されます。 - Slow In & Slow Out — 適用は
easings.out、解除はeasings.inOut。 - Timing — 適用 300ms、解除 200ms(いずれも
durationsのトークン ×tempo)。 - Secondary Action — 主役を立てている間は、周囲で副次的な動きを走らせないこと。視線が割れます。
- Appeal —
personalityのtempoで、ステージングの速さがプロダクト全体と揃います。 - アクセシビリティ —
reducedMotionの挙動と、視覚的な強調を補う ARIA。