GODRICH

React アニメーション ライブラリなしで作るCSS9選

Reactで作った画面に動きを付けたいとき最初に思い浮かぶのはreact アニメーション ライブラリの導入ですが、コンポーネントが現れたり消えたりするタイミングさえ押さえれば一行でコピペOKなCSSだけで済むことも多いです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つは人気順ではなく、Reactコンポーネントが実際にたどる順番で並べています。画面に現れて(01)→閉じると消えて(02)→その間にリストの項目が増減し(03)→状態に応じて見た目が変わり(04)→同じ登場アニメーションをもう一度再生する必要が出て(05)→それを実際にビルドする設定を挟み(06)→画面全体が別の画面に切り替わり(07)→まだ来ていないデータを待ち(08)→最後にページ自体が切り替わる(09)、という流れです。アイデア型(Reactではあまり見ない組み合わせ)は01・02・07番の3つです。

01マウント時@starting-styleフェード

カードコンポーネントがマウントされた瞬間、@starting-styleがDOMに存在しなかった時点の値を定義するため、useEffectで「マウント済み」フラグを別に作らなくてもopacityが0から1へトランジションします。条件付きで新しく描画される通知カードや、リストに追加された直後の項目に向いています。

@starting-style@keyframes mf-mountReact 19
@starting-style {
  .mf-card { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-card {
  opacity: 1;
  transform: none;
  transition: opacity $duration $easing, transform $duration $easing;
}

02アンマウントexit(allow-discrete)

閉じるボタンを押しても即座にアンマウントせず、まずclass="is-exiting"を付けてtransition-behavior: allow-discreteでdisplay切り替えを遅らせ、onTransitionEndの中で実際の状態を消します。モーダル・ダイアログを閉じるときや、トースト消去アニメーションに向いています。

allow-discreteonTransitionEndexit class
.ue-dialog {
  display: flex;
  opacity: 1;
  transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing, display $duration allow-discrete;
}
.ue-dialog.is-exiting {
  display: none;
  opacity: 0;
  transform: scale(.92) translateY(6px);
}

03リスト追加削除アニメーション

state配列に項目を追加・削除すると、新しい<li>は01番と同じ@starting-styleで入ってきて、削除される項目は02番のexitクラスのパターンをもう一度使い、アニメーションライブラリなしでもタイミングが揃います。TODOリストやカート内の行項目、通知スタックにそのまま使えます。

key@keyframes la-enter@keyframes la-leave
{items.map((it) => (
  <li
    key={it.id}
    className={`${styles.item} ${it.leaving ? styles.leaving : styles.entering}`}
    onTransitionEnd={() => it.leaving && finishRemove(it.id)}
  >
    {it.label}
    <button className={styles.remove} onClick={() => startRemove(it.id)}>×</button>
  </li>
))}

04クラス切替の状態遷移

1つのboolean状態がそのまま1つのクラス名にマッピングされ、その間の動きはすべてCSSトランジションが担当します。「状態がクラスを決め、動きはCSSが決める」というReactで最も一般的なパターンで、開閉パネルやアクティブ状態のナビ項目にそのまま使えます。

useStateclassNametransition
.tg-panel:not(.is-open) .tg-panel__chevron { transform: rotate(-90deg); }
.tg-panel:not(.is-open) .tg-panel__body { opacity: 0; transform: scaleY(0); }
.tg-panel__body {
  transform-origin: top;
  transition: opacity $duration $easing, transform $duration $easing;
}

05keyで再トリガー

key propの値を変えるとReactはそのコンポーネントを丸ごとアンマウントし、新しいインスタンスとして再マウントするため、マウント時に一度だけ再生される登場アニメーションを好きなタイミングで再生できます。確認エラーの揺れをもう一度見せたいときや、スコアが変わるたびにお祝いエフェクトを出し直したいときに使います。

key propremount@keyframes
function Badge({ value }: { value: number; key?: number }) {
  // keyが変わるたびにReactはこの要素をアンマウント・再マウントし、
  // マウント時1回限りのpopキーフレームが毎回最初から再生される。
  return <span className={styles.badge}>+{value}</span>;
}
// 呼び出し側: <Badge key={score} value={score} />

06CSS Modules+SCSS設定

この記事のreact/フォルダ9つ全部が頼っている実際のビルド設定を示す項目で、*.module.scssファイルがstyles.fooのようなローカルスコープのクラス名にコンパイルされ、グローバル衝突なしにコンポーネントごとにスタイルが閉じ込められます。コンポーネントスコープのSCSSを使いたいReactプロジェクト全般に当てはまります。

*.module.scssstyles.cardscoped classname
// styles.card・styles.titleはハードコードした文字列ではなく、
// CssModulesSetup.module.scssのクラス名がビルド時に実際にコンパイルされた値。
<div className={styles.term}>
  <div className={styles.accent}>styles.card → "{styles.card}"</div>
  <div className={styles.accent}>styles.title → "{styles.title}"</div>
</div>

07startViewTransition+状態更新

document.startViewTransition()がReact状態更新を包むと、ブラウザが変更前後のDOMスナップショットを自動的にクロスフェードするため、変更した要素ごとに手動でアニメーションをかける必要がありません。ただしコールバックの中でsetStateだけを呼ぶとReact 18の自動バッチングがコミットを遅らせてしまうため、flushSyncでその更新を同期的に強制しないとスナップショットのタイミングが合いません。

startViewTransitionflushSync@keyframes vt-cross-in
const toggle = () => {
  const doc = document as DocWithViewTransition;
  const next = mode === "list" ? "grid" : "list";
  if (doc.startViewTransition) {
    doc.startViewTransition(() => flushSync(() => setMode(next)));
  } else {
    setMode(next);
  }
};

08Suspenseスケルトン切替

React.SuspenseのfallbackスロットにCSSシマースケルトンを置いておくと、遅延読み込みされたコンポーネントの準備ができた時にfallbackが抜け、実際のコンテンツが同じ場所でフェードして入ってくるため画面がガクッとなりません。コード分割されたルートのコンテンツや、遅延読み込みされるダッシュボードウィジェットに向いています。

React.lazySuspense fallbackshimmer
.sk-bar {
  background: linear-gradient(90deg, rgba(255,255,255,.1) 25%, rgba(255,255,255,.24) 50%, rgba(255,255,255,.1) 75%);
  background-size: 200% 100%;
  animation: sk-shimmer 1.1s linear infinite;
}
@keyframes sk-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

09ルート遷移アニメーション

URLパラメータをそのままコンポーネントのkeyとして使うか、ルート切り替えをstartViewTransitionで包むと、出ていくページと入ってくるページが瞬間切り替えの代わりにCSSトランジションでつながります。SPAのルート遷移や、同じレイアウトを共有するタブ風のページに向いています。

key by routestartViewTransitionexit/enter
const navigate = () => {
  const next = (route + 1) % ROUTES.length;
  if (typeof document !== "undefined" && "startViewTransition" in document) {
    (document as any).startViewTransition(() => setRoute(next));
  } else {
    setRoute(next);
  }
};
// <Page key={ROUTES[route]} label={ROUTES[route]} />

どこで壊れるか — 9つを作って踏んだ落とし穴

実際にこの9つを作っていて一番よく踏んだ失敗は08番(Suspenseスケルトン)でした。最初の実装ではスケルトンとコンテンツを両方position: absoluteで重ねただけで、外側のカードに高さを明示していなかったため、カードそのものが潰れた40pxほどの帯としてレンダリングされてしまいました——絶対位置の子要素は親の高さ計算に入らないという、CSSを触ったことがある人なら知っている落とし穴ですが、Reactコンポーネントの中に隠れていると気づきにくいものです。実測では累積面積が0.67%から4.96%まで回復し、heightを固定してから正常になりました。もう一つは03番(リスト追加削除)で、幅を320pxまで縮めるとテキストが2行に折り返り、リスト全体の高さがビューポートをはみ出す問題です。9つ入った圧縮ファイルを開くときのパスワードはndwa6jafで、そのまま貼り付ける前に各項目へwhite-space: nowraptext-overflow: ellipsisを当てて1行に固定しておくと同じ問題を避けられます。keyを扱う05・09番にもよくある落とし穴があり、keyの値を変えずに中身だけ変えると同じインスタンスが保たれたままになり、マウントアニメーションが一度も再生されません。

アクセシビリティ

9つともprefers-reduced-motion: reduceではループアニメーションを止め、最終状態(マウントなら表示、折りたたみなら閉じた状態、ルートなら到達後の画面)にそのまま固定します。01・02番のようにトランジション自体が情報のすべてではない項目はアニメーションがなくても機能がそのまま残り、05番(keyの再トリガー)のように「もう一度知らせる」役割の項目はreduced-motionでも数値自体は即座に切り替わるため情報は失われません。zipの中のvanilla・reactコードにも同じメディアクエリがそのまま入っているので、手を加えなくてもアクセシビリティの既定値がついてきます。他に配布しているCSS作例はカテゴリー一覧に、状態をクラス1つで切り替える別の例はトグルスイッチ9選にまとめてあります。

FAQ

ReactでCSSアニメーションの代わりにアニメーションライブラリを必ず使うべきですか?

ほとんどのマウント・アンマウント・状態遷移は、この記事の9つのようにCSSとReactのフックだけで足ります。タイムラインを細かく調整したい場合や、ドラッグジェスチャーのような物理シミュレーションが必要な場合だけ、ライブラリの方が有利です。

@starting-styleはどのブラウザで使えますか?

最新のChrome・Edge・Operaは対応していますが、SafariとFirefoxは対応状況がバージョンによって分かれます。対応していないブラウザではトランジションなしでいきなり表示されるだけでレイアウトは壊れないため、プログレッシブエンハンスメントとして使っても安全です。詳しい対応状況はMDNの@starting-styleのドキュメントで確認できます。

key propを変えるとパフォーマンスに問題はありませんか?

keyが変わるたびにコンポーネントが丸ごと新しく作り直されるため、頻繁に変わるリスト全体に毎回新しいkeyを振ると負荷が大きくなります。05番のように再生トリガーが必要な小さな要素1つだけに絞って使うのがおすすめです。keyで状態を初期化する仕組みはReact公式ドキュメントに、クラス1つで状態を切り替える別の例は保存・コピーのような状態遷移9選にもまとめてあります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。