このレシピは @event/motion(実装済み・zero-dep / React のみ peer)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(starter には deps + transpilePackages が既に入っている)。上から順に実行すれば完了する。
この機能は feature flag 不要(全ページ共通の基盤層。event.config.ts の features には何も足さない。 nav も route も増えず、layout に <MotionStyles /> を 1 回置くだけで各所の JSX から使えるようになる)。
仕組みの要点(設計判断の詳細は第47章):
- スクロール連動は ネイティブ CSS scroll-driven animations(
animation-timeline: view())。
[data-scroll-fade] 属性を置くだけで JS メインスレッドを使わずに動く。 未対応環境(Firefox 既定)は @supports で静的表示に自然降格(分岐コードを書かない)
- 入場トリガは
<Reveal>(IntersectionObserver + CSS transition — 全ブラウザ共通の確実系) - 数値は
<CountUp>(入場でカウントアップ・SSR/no-JS は最終値) - モーショントークンは
--ease-out/--ease-spring(控えめオーバーシュートのスプリング)/--dur-1..3 prefers-reduced-motion: reduceで全停止(CSS 側 kill-switch + CountUp は JS 側でも即値 = 二重防御)
実 export(packages/motion/src/index.ts): MOTION_CSS / MotionStyles / Reveal(+ RevealProps)/ CountUp(+ CountUpProps)。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/motion": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/motion" を足す。
monorepo root で:
yarn install
2. layout に <MotionStyles /> を1回
apps/<app>/src/app/layout.tsx の <head> に置く(server component で描画可 — MOTION_CSS 文字列を <style> に注入するだけの純粋描画。add-design の tokensToCssVars 注入と同居できる):
import { MotionStyles } from "@event/motion";
// <head> 内に1行:
<head>
<MotionStyles />
</head>
これでトークン(--ease-out / --ease-spring / --dur-1..3)・[data-reveal] / [data-scroll-fade] / .kinetic-title のスタイル・reduced-motion kill-switch が全ページに入る。
3. <Reveal> — 入場アニメと stagger
Reveal は "use client" 済みなので Server Component の page からそのまま使える。 ビューポート入場(IntersectionObserver・threshold 0.12)で .is-in が付き、 opacity 0 → 1 + translateY(14px) → 0 が var(--dur-2)(480ms)var(--ease-out) で走る:
import { Reveal } from "@event/motion";
// 単発(既定 dir="up")
<Reveal>
<h1>イベント名</h1>
</Reveal>
// 方向と遅延
<Reveal dir="left" delay={120}>
<p>横から入る要素</p>
</Reveal>
// stagger — 親側で index * 60ms を渡すだけ
{items.map((item, i) => (
<Reveal key={item.id} delay={i * 60}>
<article>{item.title}</article>
</Reveal>
))}
props(実装 packages/motion/src/Reveal.tsx の転写):
| prop | 型 / 既定 | 意味 | |||
|---|---|---|---|---|---|
dir | `"up" \ | "left" \ | "right" \ | "scale"(既定 "up"`) | 入場方向(left/right は ±18px、scale は 0.96 から) |
delay | number(ms・既定 0) | --reveal-delay に写像(transition-delay)。stagger は親が index で計算 | |||
once | boolean(既定 true) | 一度入場したら以後監視しない。false で出入りのたびにトグル | |||
className | string? | ラッパー div に付与 |
IntersectionObserver が無い環境では即 .is-in(常に見える)。CSS 未注入(no-JS で <MotionStyles /> も無い等) でも [data-reveal] スタイルが無ければ素で見える — fail-visible の設計。
4. <CountUp> — 数値カウントアップ
ビューポート入場(threshold 0.4)で 0 → to へ easeOutCubic のカウントアップ。 SSR は最終値を出す(no-JS でも数字が見える)。reduced-motion では即座に最終値:
import { CountUp } from "@event/motion";
<CountUp to={46} />
<CountUp to={1331} duration={1200} suffix=" 票" />
<CountUp to={20} suffix=" パッケージ" />
> **RSC 注意**: `format` は関数 prop のため **Server Component から渡すと実行時エラー**
> (Functions cannot be passed directly to Client Components)。Server Component の page では
> 直列化可能な `prefix` / `suffix` を使い、`format` は client component 内でのみ使う。
props(実装 packages/motion/src/CountUp.tsx の転写): to: number(最終値)/ format?: (n: number) => string(表示整形・既定そのまま)/ duration?: number(ms・既定 900)/ className?。
5. [data-scroll-fade] — ネイティブ scroll-driven
component 不要・JS 不要。属性を置くだけ:
<section data-scroll-fade>
<h2>スクロールで浮かび上がるセクション</h2>
</section>
MOTION_CSS 側の実装(転写):
@supports (animation-timeline: view()) {
@keyframes motionViewFade {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: none; }
}
[data-scroll-fade] {
animation: motionViewFade linear both;
animation-timeline: view();
animation-range: entry 0% entry 68%;
}
}
- 要素がビューポートに入り始め〜68% 進入の区間でスクロール量に同期してフェードイン
(linear なのは進行がスクロール位置そのものだから — easing はスクロール操作が担う)
@supportsの外では何も適用されない = 未対応ブラウザは最初から見えている。
「JS で IntersectionObserver に降格」のような分岐は書かない(それは <Reveal> の役割)
6. kinetic-title — ロード時のクリップ立ち上がり
見出し用。overflow: hidden の行から文字ブロックがせり上がる。--k-i で 70ms ずつの stagger:
<h1 className="kinetic-title">
<span>EVENT 2026</span>
</h1>
// 複数語 / 複数行は --k-i を増やす(span ごとに 70ms 遅延)
<h1 className="kinetic-title">
<span style={{ "--k-i": 0 } as React.CSSProperties}>統合</span>{" "}
<span style={{ "--k-i": 1 } as React.CSSProperties}>イベント</span>{" "}
<span style={{ "--k-i": 2 } as React.CSSProperties}>基盤</span>
</h1>
(.kinetic-title > span が motionRiseIn(0.6em 下から・0.7s・--ease-out)で立ち上がる。 Reveal と違いロード時に即走る — ファーストビューの見出し向け。スクロール到達で出すなら Reveal を使う。)
補助 keyframes として motionPulseX(左→右へ流れて消える)/ motionPopIn(scale 0.6 → 1)も MOTION_CSS に入っており、図解の「流れ」表現などに animation: motionPulseX 2s var(--ease-out) infinite の形で使える。
7. 検証
yarn workspace <app> typecheck # 型が通ること
yarn workspace <app> dev
- トップページで
<Reveal>要素が下から 14px 立ち上がる。list の stagger がdelay={i * 60}の順に走る <CountUp to={46} />が視界に入った時点で 0 → 46 へ約 0.9s でカウントアップ(初速が速い easeOutCubic)- Chrome / Edge / Safari 26:
[data-scroll-fade]セクションがスクロール進行に同期してフェードイン。
スクロールを戻すと逆再生される(scroll-driven はスクロール位置への写像)。 Firefox(既定): 同じセクションが最初から静的に見えている(欠落ではなく降格)
- OS の「視覚効果を減らす」を ON(Windows: 設定 > アクセシビリティ > 視覚効果 > アニメーション効果 OFF)
→ リロードで 全モーションが停止: Reveal は即表示・CountUp は即最終値・scroll-fade / kinetic-title も静止
完了条件: typecheck が通り、dev で入場アニメとカウントアップが動き、reduced-motion で全停止する。