ReactでWeb Animations APIを使って要素を動かす
Web Animations API を使うと、CSS のクラスを付け外ししなくても、要素の animate() を呼ぶだけで動きを付けられます。React では、ref で要素を受け取り、クリックのハンドラやエフェクトの中で animate() を呼びます。
ここでは、クリックで要素を揺らす、表示したときにふわっと出す、消える前に薄くする、の3つを作りました。3つ目の動きでは薄くなった姿を残す必要があるので、3つ目の前の節で、動き終わった位置を残す方法(fill: 'forwards' と commitStyles())を確かめます。どの場面でも、書き方を変えたときにブラウザの中で何が起きるかを数えて比べています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chrome for Testing 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.56.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作したブラウザで動かしました。StrictMode の確認だけは開発用のビルドで、それ以外は本番用のビルドで動かしています。Firefox と Safari では試していません。
クリックで要素を揺らす
送信ボタンを押したら、入力欄の箱を左右に揺らします。動かしたい要素を useRef で受け取り、クリックのハンドラで animate() を呼びます。
import { useRef } from 'react';
import { prefersReducedMotion } from './motion';
const shake: Keyframe[] = [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-8px)' },
{ transform: 'translateX(8px)' },
{ transform: 'translateX(-8px)' },
{ transform: 'translateX(0)' },
];
export function ShakeBox() {
const boxRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<Animation | null>(null);
function handleClick() {
if (prefersReducedMotion()) return;
// 前の揺れが残っていれば止めてから、最初から揺らし直す
animationRef.current?.cancel();
animationRef.current =
boxRef.current?.animate(shake, { duration: 400, easing: 'ease-in-out' }) ?? null;
}
return (
<>
<div ref={boxRef} className="box">入力してください</div>
<button onClick={handleClick}>送信</button>
</>
);
}
MDN の animate() のページにあるとおり、animate() の1つ目の引数はキーフレームの配列、2つ目は長さ(ミリ秒)やイージングです。呼ぶとすぐに再生が始まり、戻り値の Animation で止めたり、終わるのを待ったりできます。prefersReducedMotion() は、後で出てくる、動きを減らす設定を調べる関数です。
ボタンを1回押して、箱の getAnimations() の数と transform を見ました。
押した直後: {"running":1,"created":1,"transform":"matrix(1, 0, 0, 1, 0, 0)","styleAttr":null}
100ms後: {"running":1,"created":1,"transform":"matrix(1, 0, 0, 1, -5.66805, 0)","styleAttr":null}
600ms後: {"running":0,"created":1,"transform":"none","styleAttr":null}
running は箱に付いているアニメーションの数、created はそれまでに animate() で作った数です。100ms後には左へ約5.7px動いていて、400msの揺れが終わった600ms後には、アニメーションは要素から外れ、transform は none に戻りました。style 属性には何も書かれていません。animate() は React が描画した DOM の属性を書き換えないので、React の描画とぶつかりません。

連打されたときは、前の揺れを止める
animate() は、呼ぶたびに新しい Animation を作ります。前の揺れが終わる前にもう一度押されると、1つの要素に複数のアニメーションが重なります。100msおきに3回押して、3回目の100ms後の状態を比べました。1行目が前の揺れを止めない版、2行目が上の ShakeBox です。
shake-nocancel 3回目の100ms後: {"running":3,"playStates":"running,running,running","transform":"matrix(1, 0, 0, 1, -2.84667, 0)"}
shake 3回目の100ms後: {"running":1,"playStates":"idle,idle,running","transform":"matrix(1, 0, 0, 1, -2.84112, 0)"}
止めない版では3つの揺れが同時に動いていました。それでも箱の位置(transform の5つ目の値)は、最後の揺れだけが動いている版とほぼ同じです。見た目は大きく変わりませんが、終わっていない揺れが裏で動き続けます。
ShakeBox では、作った Animation を ref に入れておき、次に押されたときに cancel() で止めています。止めた揺れは idle になり、要素に付いているのは最後の1つだけでした。ref に入れるのは、Animation が画面の表示に使う値ではないからです。useRef のリファレンスにあるとおり、ref の中身を変えても再描画は起きません。
表示したときにふわっと出す
パネルを表示したとき、上から少し下がりながら現れるようにします。表示した直後に1回だけ動かすので、エフェクトの中で animate() を呼び、後片付けで cancel() します。
import { useLayoutEffect, useRef } from 'react';
import { prefersReducedMotion } from './motion';
const enter: Keyframe[] = [
{ opacity: 0, transform: 'translateY(-12px)' },
{ opacity: 1, transform: 'translateY(0)' },
];
export function Panel() {
const panelRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (prefersReducedMotion()) return;
const animation = panelRef.current?.animate(enter, { duration: 300, easing: 'ease-out' });
return () => animation?.cancel();
}, []);
return <div ref={panelRef} className="panel">保存しました</div>;
}
useEffect では、動く前の姿が一瞬見えることがある
ここで useEffect ではなく useLayoutEffect を使ったのは、ちらつきを防ぐためです。パネルが DOM に入った最初のフレームで、パネルの透明度(opacity)とアニメーションの数を requestAnimationFrame で調べました。パネルを、ボタンのクリックで表示した場合と、setTimeout の中で表示した場合を、それぞれ50回ずつ試しています。
panel-effect クリック {"opacity=0 running=1":50}
panel-effect setTimeout {"opacity=1 running=0":8,"opacity=0 running=1":42}
panel クリック {"opacity=0 running=1":50}
panel setTimeout {"opacity=0 running=1":50}
panel-effect が useEffect で始める版、panel が上の useLayoutEffect の版です。useEffect の版では、setTimeout から表示したときに50回中8回、最初のフレームでアニメーションがまだ始まっておらず、パネルは opacity が1のまま描かれていました。透明から始まるはずのパネルが、一瞬はっきり見えてから消え、もう一度現れることになります。useLayoutEffect の版では、50回とも最初のフレームから動いていました。
この違いは、useEffect のリファレンスの注意書きのとおりです。クリックのような操作で始まった更新では、エフェクトが画面を描く前に動くことがあります。それ以外の更新では、React はふつう先に画面を描かせてからエフェクトを動かします。useLayoutEffect は、ブラウザが画面を描き直す前に必ず動きます。データの取得が終わったときなど、クリック以外のきっかけで表示する部品に動きを付けるなら、useLayoutEffect にしておくと安全です。
StrictMode では2回作られ、1回止められる
StrictMode では、開発中に限り、エフェクトのセットアップと後片付けが1回余分に動きます。開発用のビルドを StrictMode で動かし、パネルを出した直後の状態を見ました。1行目が上の Panel、2行目が後片付けで cancel() しない版です。
panel 出した直後: {"running":1,"created":2,"playStates":"idle,running"}
panel-nocleanup 出した直後: {"running":2,"created":2,"playStates":"running,running"}
どちらも animate() は2回呼ばれています。後片付けがある版では、1つ目が idle に止められ、動いているのは1つだけでした。後片付けがない版では、2つが同時に動いていました。同じ動きが2つ重なるだけなので、見た目には出にくい違いです。なお、useLayoutEffect のリファレンスにあるとおり、後片付けの関数はコンポーネントが DOM から外れる前にも呼ばれます。動いている途中でパネルを消したときも、cancel() が呼ばれます。
動き終わった位置を残す
animate() の動きは、終わると元に戻ります。次の節で、通知を消えた状態のまま残すのにこの方法を使うので、ここで残し方を確かめておきます。箱を右へ200px動かし、そのまま止めておきたいときは、fill: 'forwards' を付けるか、commitStyles() で最後の値を style 属性に書き込みます。下のコードで両方を使っているのは、新しいブラウザでは fill がなくても commitStyles() で書き込めますが、古いブラウザでは fill が必要なためです。fill で残ったアニメーションは、書き込んだあとで cancel() して外します。3つの書き方で、動き終わったあとの状態を比べました。
const animation = box.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(200px)' }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' },
);
await animation.finished;
// 最後の状態を style 属性に書き込み、アニメーションは片付ける
animation.commitStyles();
animation.cancel();
move-none {"running":0,"transform":"none","styleAttr":null}
move-fill {"running":1,"transform":"matrix(1, 0, 0, 1, 200, 0)","styleAttr":null}
move-commit {"running":0,"transform":"matrix(1, 0, 0, 1, 200, 0)","styleAttr":"transform: translateX(200px);"}
1行目は fill も commitStyles() も使わない版で、箱は元の位置に戻りました。2行目は fill: 'forwards' だけの版で、箱は200pxの位置に残りますが、終わったアニメーションが要素に付いたままです。3行目が上のコードで、style 属性に transform: translateX(200px); が書き込まれ、アニメーションは外れました。
fill: 'forwards' だけで済ませない理由は、MDN の commitStyles() のページに書かれています。アニメーションの値は、普通のスタイルより優先されます。付いたままにしておくと、あとでクラスや style を変えても、その要素の transform が変わらなくなります。
commitStyles() は、React が描画した要素の style 属性を、React を通さずに書き換えます。ref を使った DOM の操作についての React のドキュメントでは、React が管理する DOM を書き換えるなら、React が更新する理由のない部分だけにするよう勧めています。commitStyles() で残すプロパティは、style の props で指定していないものにしてください。
fill: 'forwards' を付けた揺れを500msおきに3回動かしたところ、要素に残ったアニメーションは最後の1つでした。前の2つの replaceState は removed です。
shake-fill 終わったあと: {"running":1,"replaceStates":"removed,removed,active"}
MDN の Web Animations API の使い方のページにあるとおり、動き終わった fill 付きのアニメーションは、同じプロパティを動かす新しいアニメーションに上書きされると、ブラウザが自動で外します。同じ動きを繰り返すだけなら、付いたままのアニメーションが増え続けることはありません。
消える前に薄くする
通知の「閉じる」を押したら、右へずれながら薄くなり、そのあとで DOM から外します。React の state で消すと要素はすぐに DOM から外れるので、先に animate() で動かし、finished の Promise を待ってから state を変えます。
import { useRef, useState } from 'react';
import { prefersReducedMotion } from './motion';
const exit: Keyframe[] = [
{ opacity: 1, transform: 'translateX(0)' },
{ opacity: 0, transform: 'translateX(24px)' },
];
function Notice({ onClose }: { onClose: () => void }) {
const noticeRef = useRef<HTMLDivElement>(null);
const closingRef = useRef(false);
async function handleClose() {
const notice = noticeRef.current;
if (!notice || closingRef.current) return;
closingRef.current = true;
if (!prefersReducedMotion()) {
// 消えた状態のまま残すため fill: 'forwards' を付ける
const animation = notice.animate(exit, { duration: 250, easing: 'ease-in', fill: 'forwards' });
await animation.finished;
}
onClose();
}
return (
<div ref={noticeRef} className="notice">
保存しました
<button onClick={handleClose}>閉じる</button>
</div>
);
}
export function NoticeArea() {
const [open, setOpen] = useState(true);
return (
<>
<button onClick={() => setOpen(true)}>通知を出す</button>
{open && <Notice onClose={() => setOpen(false)} />}
</>
);
}
closingRef は、閉じる動きを始めたときに true にします。薄くなっている途中でもう一度「閉じる」が押されても、true なら何もせずに戻るので、動きは2回作られません。続けて2回押したところ、作られたアニメーションは1つで、500ms後には通知が DOM から外れていました。
2回押した直後: 作られたアニメーション 1
500ms後: 通知 0 個
fill を付けないと、消える直前に元の姿に戻る
ここで fill: 'forwards' を付けているのは、動き終わってから DOM から外れるまでのあいだに、元の姿が一瞬見えるのを防ぐためです。「閉じる」を押してから通知が消えるまで、フレームごとに透明度を記録しました。1行目が fill を付けない版、2行目が上のコードです。
notice-nofill 1回目の記録: 1.00 1.00 1.00 1.00 0.99 0.97 0.94 0.90 0.84 0.79 0.72 0.65 0.57 0.49 0.40 0.31 0.21 0.11 1.00 なし
notice 1回目の記録: 1.00 1.00 1.00 1.00 1.00 0.99 0.97 0.94 0.89 0.84 0.78 0.72 0.65 0.57 0.49 0.40 0.31 0.21 0.11 0.00 0.00 なし
fill を付けない版の1回目の記録では、透明度が0.11まで下がった次のフレームで1.00に戻り、その次のフレームで DOM から外れました。20回試して、20回とも消える前に1.00に戻っています。動きが終わった時点で元のスタイルに戻り、finished を待ってから React が DOM を書き換えるまでのあいだに、1フレーム描かれたと考えられます。fill: 'forwards' を付けた版では、20回とも1.00に戻りませんでした。この要素はすぐに DOM から外れるので、fill を付けたままにしても、fill だけで済ませたときのような困りごとはありません。
途中で cancel() すると、finished の Promise は AbortError で reject されます(MDN の cancel() のページ)。この通知では閉じる動きを止める場面がないので、reject の扱いは書いていません。動きを途中で止める作りにするなら、await を try で囲んでください。
動きを減らす設定を尊重する
OS で動きを減らす設定をしている人には、動きを付けないようにします。CSS の prefers-reduced-motion と同じ条件を、matchMedia() で JavaScript から調べられます。上の3つのコンポーネントは、どれもこの関数で調べてから動かしています。
// 動きを減らす設定がオンなら true
export function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
Playwright で reducedMotion: 'reduce' を指定してページを開き、送信ボタンと「閉じる」を押しました。
matchMedia: true
shake 押した直後: {"running":0,"created":0}
notice 閉じた直後: 0 個 / 作られたアニメーション 0
アニメーションは1つも作られず、通知は押してすぐに消えました。





