ガイド 第47

モーショングラフィックス

Reveal・CountUp・scroll-driven・spring

@event/motion

@event/motion はイベントサイトのモーショングラフィックス・プリミティブ層である。zero-dep(peer は React のみ)で、公開面は 4 つだけ — MOTION_CSS(トークン + keyframes + scroll-driven の CSS 文字列)/ MotionStyles(それを <style> 注入する server 描画可能 component)/ Reveal(入場)/ CountUp(数値)。配線は layout に <MotionStyles /> を 1 回置くだけ(手順はレシピ add-motion)。デザイントークン層(第44章)が「静的な見た目」の基盤層であるのと対で、これは「動き」の基盤層 — feature flag 不要、nav も route も増えない。

この章は API 説明よりも 2026 年時点の設計判断を記録する。モーションライブラリの選定は流行で覆りやすく、「なぜこの構成か」が残っていないと次のイベントで安易に重量級ライブラリが入るからである。

判断1 — スクロール連動はネイティブ CSS scroll-driven

2026 年の最大の変化は、CSS scroll-driven animations(animation-timeline: view())がユニバーサル対応に入ったことである。Chrome / Edge は先行実装済み、Safari 26 が追随し、主要 3 エンジン中 2 つで動く。これが意味するのは:

  • JS メインスレッドを使わない。 scroll イベントも IntersectionObserver も rAF ループも無しで、コンポジタがスクロール位置をアニメーション進行に直接写像する。スクロール連動系で最も壊れやすい「スクロール中の jank」が構造的に消える
  • スクロールとの完全同期。「スクロール量 = 進行度」なので、戻せば逆再生される。トリガ発火型では作れない質感

実装は 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%;
  }
}
  • 使う側は <section data-scroll-fade>属性を置くだけ。component も import も不要
  • animation-range: entry 0% entry 68% — 要素がビューポートに入り始めてから 68% 進入するまでの区間で完了する。「読み始める位置に来たときには出現し終えている」を狙った値
  • easing が linear なのは意図的。進行がスクロール位置そのものなので、緩急はユーザーのスクロール操作が担う。ここに ease を掛けると指の動きと表示がズレて酔う

未対応環境(Firefox 既定)の扱いが判断の核心: @supports の外では [data-scroll-fade] に何も適用されない = 最初から見えている。「JS で polyfill する / IO に降格する」を選ばなかったのは、この層の scroll-fade が装飾であり、装飾のためにメインスレッド常駐コードを全ブラウザに配るのは本末転倒だからである。降格後の状態は「アニメが無いだけの完全なページ」— fail-visible。

判断2 — 入場トリガは IntersectionObserver 併用(Reveal)

では全部 scroll-driven でよいかというと、そうではない。「連動」と「発火」は別の道具である:

scroll-driven([data-scroll-fade]Reveal(IO + CSS transition)
性質スクロール位置への連続写像入場イベントで1回発火
逆スクロール逆再生されるonce(既定)なら再生済みのまま
stagger不可(各要素が自分の位置に連動)delay で自在(i * 60 など)
easinglinear(操作が緩急を担う)--ease-out / --ease-spring が使える
対応Chrome/Edge/Safari 26(他は静的降格)全ブラウザ(IO は 2019 年から普遍)

「カードが順番に立ち上がる」「見出しが1回だけ気持ちよく入る」は発火型の仕事で、これをネイティブ scroll-driven でやると逆スクロールのたびに引っ込んでうるさい。逆に「スクロールに吸い付くフェード」を IO でやると段付きになる。2系統を併記したのは冗長ではなく役割分担である。

Reveal の実装は最小限(packages/motion/src/Reveal.tsx): IO(threshold 0.12)で .is-in を付け、見た目は MOTION_CSS[data-reveal] transition に委譲する。JS は class を付けるだけで、動きは全部 CSS — メインスレッドの仕事は入場検知の一瞬だけ。props は dir(up / left / right / scale)・delay(ms — --reveal-delay に写像)・once(既定 true)・className

import { Reveal } from "@event/motion";

{items.map((item, i) => (
  <Reveal key={item.id} delay={i * 60}>
    <article>{item.title}</article>
  </Reveal>
))}

SSR / no-JS では .is-in が付かないが、CSS も注入されていなければ素で見える。IO が無い環境では即 .is-inどの経路でもコンテンツが見えなくなる分岐が無いことが、マーケティングサイトで opacity: 0 スタートのモーションを使う際の絶対条件である。

判断3 — スプリング easing をトークン化する

モーションの質感はトークン 5 つに集約した(MOTION_CSS 冒頭・実値):

:root {
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.45, 0.5, 1); /* 控えめなオーバーシュート */
  --dur-1: 240ms;
  --dur-2: 480ms;
  --dur-3: 800ms;
}
  • --ease-spring は第2制御点の y=1.45 が 1 を超えることで着地前に少し行き過ぎて戻る、物理バネの簡易近似。2026 年の主要デザインシステム(Material 3 Expressive 等)が「機械的な ease より物理感」に振れた流れをトークン1個で取り込む。本物のスプリング物理(減衰振動)が要るなら linear() 関数やライブラリの領域だが、イベントサイトの入場・ポップには過剰
  • 使い分けの目安: 通常の入場・フェードは --ease-out、ボタンのポップやバッジ出現など「弾み」が欲しい一点だけ --ease-spring。全部をスプリングにすると安っぽくなる
  • duration は 3 段だけ(240 / 480 / 800ms)。micro(hover 等)/ 標準入場 / 大物の 3 レンジで、任意 ms の散乱を防ぐ

イベント app 側の component も transition: transform var(--dur-1) var(--ease-spring) のようにトークンを参照すれば、サイト全体のモーションの「速度感」を 1 箇所で調律できる — デザイントークン(第44章)と同じ思想の時間軸版である。

判断4 — reduced-motion は二重防御

prefers-reduced-motion: reduce への対応は CSS と JS の2層で行う:

第1層 — CSS kill-switchMOTION_CSS 末尾。宣言型モーションを一括停止):

@media (prefers-reduced-motion: reduce) {
  [data-reveal],
  [data-scroll-fade],
  .kinetic-title > span {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
    transition: none !important;
  }
}

第2層 — JS 側の即値CountUp は state 更新で数字を書き換えるため CSS では止められない):

const reduced =
  typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || typeof IntersectionObserver === "undefined") return; // rAF ループ自体を起動しない

2層必要な理由は停止対象の性質が違うからである。CSS 由来の動き(reveal / scroll-fade / kinetic-title)は CSS で殺すのが確実で漏れない(!important は「後から足された個別スタイルにも勝つ」ための明示的な採用)。一方 CountUp のカウントは JS の setVal 連打なので、CSS を止めても数字はパラパラ変わり続ける — JS 側で rAF ループの起動そのものを抑止し、SSR 初期値(= 最終値)のまま表示する。「アニメ用 CSS を止める」と「アニメ用 JS を走らせない」は別の仕事であり、片方だけだと必ず漏れる。加えてアプリ側 globals.css に全体 kill-switch があればそれとも重なる(防御は重ねてよい — 停止系の冪等な上書きは競合しない)。

CountUp と kinetic-title

CountUppackages/motion/src/CountUp.tsx)は入場(IO threshold 0.4)で 0 → to を easeOutCubic / 既定 900ms でカウントアップする。設計上の要点は SSR / no-JS で最終値を出すこと — 初期 state が to で、JS が動いてから setVal(0) して開始する。統計数字はクローラと no-JS 読者にも「本当の値」が見えていなければならない(0 のまま止まった統計は誤情報になる)。

import { CountUp } from "@event/motion";

<CountUp to={1331} duration={1200} format={(n) => `${n.toLocaleString()} 票`} />

.kinetic-title はロード時のクリップ立ち上がり(overflow: hidden の行から > span が 0.6em せり上がる・--k-i で 70ms stagger)。Reveal がスクロール到達で発火するのに対し、こちらはファーストビューの見出し専用にマウント即走る。ほかに図解の「流れ」用 keyframes(motionPulseX / motionPopIn)も MOTION_CSS が持つ。

なぜライブラリを入れなかったか

GSAP や Motion(旧 Framer Motion)を採らなかったのは能力の問題ではなく責務の問題である。この層が担うのは「イベントサイトの標準演出」= 入場・カウント・スクロールフェード・見出しの4種で、全てネイティブ(CSS + IO + rAF)で数十行に収まる。ライブラリを基盤層に入れると全イベント app が bundle と更新追従を背負う。timeline 同期や SVG モーフのような演出固有の要件が出たイベントは、その app だけにライブラリを足せばよい(基盤層はそれを妨げない)。エンジンは薄く、重さは要るところにだけ — モジュール全体の設計原則(第2章)のモーション版である。