ReactでアニメーションGIFをコマに分けて表示する方法
ブラウザの ImageDecoder に GIF のデータを渡し、decode({ frameIndex }) でコマの番号を指定すると、そのコマの画像を1枚ずつ取り出せます。表示時間も一緒に返るので、GIF をコマ送りで見たり、各コマが何ミリ秒表示されるかを一覧にしたりできます。
ここでは、選んだ GIF をコマに分け、「前のコマ」「次のコマ」のボタンと、表示時間つきのサムネイルの一覧で見せるコンポーネント GifFrames を作りました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild でビルドし、http://localhost:5173 で開いたページを Playwright で操作しました。読み込ませた GIF は、Pillow で描いた 240×160 の小さな3つのファイル(6コマでずっとくり返すもの、3コマでくり返さないもの、1コマだけのもの)です。Firefox、Safari、スマートフォンのブラウザ、透明な部分のある GIF、何百コマもある大きな GIF は試していません。

ImageDecoder が使えるブラウザ
ImageDecoder は WebCodecs API の一部です。MDN の互換性の表では、Chrome 94、Firefox 133 から使え、Safari は Technology Preview だけが対応になっています(2026年11月8日に確認)。また、HTTPS のページか localhost でだけ使えます。
そのため、コードでは最初に ImageDecoder があるかを確かめます。ImageDecoder.isTypeSupported() を使うと、形式ごとに読めるかどうかも分かります。検証した Chromium では、image/gif・image/png・image/webp・image/avif・image/jpeg が true、image/svg+xml が false でした。
GIF をコマごとの PNG にする関数
decodeGifFrames は、GIF のファイルを受け取り、コマごとの画像の URL と表示時間を返します。
export type GifFrame = {
url: string; // コマを PNG にした Blob の URL
durationMs: number; // このコマを表示する時間
};
export type DecodedGif = {
width: number;
height: number;
repetitionCount: number; // くり返す回数。ずっとくり返すなら Infinity
frames: GifFrame[];
};
export async function decodeGifFrames(file: Blob, signal: AbortSignal): Promise<DecodedGif> {
if (typeof ImageDecoder === 'undefined') throw new Error('このブラウザは ImageDecoder に対応していません');
if (!(await ImageDecoder.isTypeSupported('image/gif'))) throw new Error('GIF を読めません');
const decoder = new ImageDecoder({ data: file.stream(), type: 'image/gif' });
const urls: string[] = [];
try {
// データを最後まで読み終えてから、コマの数を見る
// (壊れたデータでは tracks.ready が先に失敗するので、両方を待つ)
await Promise.all([decoder.tracks.ready, decoder.completed]).catch((e: unknown) => {
throw new Error('GIF として読めませんでした', { cause: e });
});
const track = decoder.tracks.selectedTrack;
if (!track) throw new Error('画像を読めませんでした');
const frames: GifFrame[] = [];
let canvas: OffscreenCanvas | null = null;
for (let i = 0; i < track.frameCount; i++) {
const { image } = await decoder.decode({ frameIndex: i });
let durationMs: number;
try {
canvas ??= new OffscreenCanvas(image.displayWidth, image.displayHeight);
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
durationMs = (image.duration ?? 0) / 1000; // duration はマイクロ秒
} finally {
image.close(); // VideoFrame はすぐに閉じる
}
const blob = await canvas.convertToBlob({ type: 'image/png' });
signal.throwIfAborted();
const url = URL.createObjectURL(blob);
urls.push(url);
frames.push({ url, durationMs });
}
return { width: canvas?.width ?? 0, height: canvas?.height ?? 0, repetitionCount: track.repetitionCount, frames };
} catch (e) {
urls.forEach((u) => URL.revokeObjectURL(u));
throw e;
} finally {
decoder.close();
}
}
ImageDecoder のコンストラクターの data には、ArrayBuffer や ReadableStream を渡せます。ここではファイルの stream() を渡しました。ArrayBuffer で渡しても、同じく6コマとして読めました。
decode() が返すのは VideoFrame です。canvas の drawImage() にそのまま渡せます。描いたあとは close() を呼んで、VideoFrame が持っている画像のデータへの参照を手放します。描いた canvas を convertToBlob() で PNG にし、その URL を <img> に渡します。
VideoFrame の duration はマイクロ秒です。100ms のコマでは 100000 が入っていたので、1000 で割ってミリ秒にしています。timestamp には、そのコマが始まる時刻が入っていました。6コマの GIF で各コマの値を読むと、次のとおりです(左上の画素の色も一緒に出しています。このあと使います)。
count.gif tracks.length 1 animated true frameCount 6 repetitionCount Infinity complete true
コマ1 complete true size 240x160 format BGRX timestamp 0 duration 100000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマ2 complete true size 240x160 format BGRX timestamp 100000 duration 100000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマ3 complete true size 240x160 format BGRX timestamp 200000 duration 200000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマ4 complete true size 240x160 format BGRX timestamp 400000 duration 200000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマ5 complete true size 240x160 format BGRX timestamp 600000 duration 400000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマ6 complete true size 240x160 format BGRX timestamp 1000000 duration 800000 左上(5,5) 0,200,232,255 四角(135,140) 216,88,120,255
コマの数は、データを読み終えてから数える
tracks.ready は、画像の情報(トラック)が分かった時点で解決します。ただ、その時点ではまだ全部のデータを読んでいないことがあります。仕様にも、ReadableStream で渡したときはデータが届くにつれて frameCount が変わりうると書かれています。
これを確かめるため、6コマの GIF を 2048 バイトずつ、30ms おきに流すストリームで渡しました。tracks.ready の直後の frameCount は 2 で、completed が解決したあとは 6 でした。
slow: tracks.ready の直後 frameCount 2 complete false
slow: 途中で見えた frameCount 2 → 6 completed のあと 6 complete true
tracks.ready の直後に読んだ frameCount をそのまま使うと、この場合は2コマしか分かりません。そのため、decodeGifFrames では completed も待ってから frameCount を読んでいます。
Promise.all で tracks.ready と一緒に待っているのは、GIF でないデータを渡したときのためです。tracks.ready は InvalidStateError で失敗し、completed は AbortError(Aborted by close.)で失敗しました。completed だけを待つと、cause に入るのは原因が分かりにくい AbortError のほうになります。
tracks.ready: InvalidStateError Failed to retrieve track metadata.
completed: AbortError Aborted by close.
差分しか入っていないコマも、1枚の絵として返る
GIF の2コマ目以降には、前のコマから変わった部分だけが入っていることがあります。検証に使った GIF もそうなっています。Pillow で読むと、1コマ目は全体(240×160)が入っていますが、2コマ目以降は上から 82 ピクセルより下の、変わった範囲だけでした。上の水色の帯は、1コマ目のデータにしか入っていません。
count.gif (240, 160) コマ数 6 loop 0
コマ 1 duration 100 入っている領域(左,上,右,下) (0, 0, 240, 160)
コマ 2 duration 100 入っている領域(左,上,右,下) (20, 82, 197, 136)
コマ 3 duration 200 入っている領域(左,上,右,下) (35, 82, 197, 137)
それでも、decode() が返したコマは、どれも 240×160 でした。帯の中にある左上の画素も、6コマとも水色(0,200,232)でした。前のコマと重ねた、画面に見えるとおりの1枚が返ってくるので、自分で重ねる処理は要りません。PNG にしたあとの1コマ目と4コマ目でも、左上の画素は同じ水色でした。
コマを選んで表示するコンポーネント
GifFrames は、ファイルが選ばれたら decodeGifFrames を呼び、できたコマを表示します。
import { useEffect, useState, type ChangeEvent } from 'react';
import { decodeGifFrames, type DecodedGif } from './decodeGif';
export function GifFrames() {
const [file, setFile] = useState<File | null>(null);
const [gif, setGif] = useState<DecodedGif | null>(null);
const [error, setError] = useState('');
const [index, setIndex] = useState(0);
useEffect(() => {
if (!file) return;
const controller = new AbortController();
let result: DecodedGif | null = null;
decodeGifFrames(file, controller.signal).then(
(r) => {
if (controller.signal.aborted) {
r.frames.forEach((f) => URL.revokeObjectURL(f.url));
return;
}
result = r;
setGif(r);
},
(e: unknown) => {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : String(e));
},
);
return () => {
controller.abort();
result?.frames.forEach((f) => URL.revokeObjectURL(f.url));
};
}, [file]);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setFile(e.target.files?.[0] ?? null);
setGif(null);
setError('');
setIndex(0);
};
const total = gif?.frames.reduce((sum, f) => sum + f.durationMs, 0) ?? 0;
const frame = gif?.frames[index];
return (
<div className="gif-frames">
<input type="file" accept="image/gif" onChange={onChange} />
{error && <p role="alert">{error}</p>}
{file && !gif && !error && <p>読み込み中…</p>}
{gif && frame && (
<>
<p className="summary">
{gif.width}×{gif.height}・{gif.frames.length}コマ・1周 {total}ms・
{gif.repetitionCount === Infinity
? 'ずっとくり返す'
: gif.repetitionCount === 0
? 'くり返さない'
: `${gif.repetitionCount}回くり返す`}
</p>
<img className="current" src={frame.url} alt={`${index + 1}コマ目`} />
<div className="controls">
<button onClick={() => setIndex((i) => i - 1)} disabled={index === 0}>
前のコマ
</button>
<output>
{index + 1} / {gif.frames.length} コマ目({frame.durationMs}ms)
</output>
<button onClick={() => setIndex((i) => i + 1)} disabled={index === gif.frames.length - 1}>
次のコマ
</button>
</div>
<ol className="strip">
{gif.frames.map((f, i) => (
<li key={f.url}>
<button onClick={() => setIndex(i)} aria-current={i === index}>
<img src={f.url} alt={`${i + 1}コマ目`} />
<span>{f.durationMs}ms</span>
</button>
</li>
))}
</ol>
</>
)}
</div>
);
}
コマの画像は Blob の URL なので、要らなくなったら URL.revokeObjectURL() で解放します。別のファイルを選ぶと file が変わり、effect のクリーンアップ関数で前の GIF の URL をまとめて解放します。検証では、別のファイルを選んだあと、前に表示していたコマの URL は fetch で読めなくなっていました。
読み込みの途中で別のファイルが選ばれることもあります。クリーンアップ関数で controller.abort() を呼ぶと、decodeGifFrames は次のコマに進む前に signal.throwIfAborted() で止まり、それまでに作った URL を解放します。読み終わったのが中止のあとだった場合は、then の中で URL を解放して、表示には使いません。6コマの GIF を選んですぐに3コマの GIF を選び直したところ、表示は「3コマ」、サムネイルも3つで、前のファイルのコマは出ませんでした。
「次のコマ」を押していくと、大きな画像と「何コマ目か」の表示が切り替わります。最後のコマでは「次のコマ」が押せなくなります。

くり返しの回数は repetitionCount で分かる
track.repetitionCount には、アニメーションをくり返す回数が入ります。仕様では、Infinity がずっとくり返すことを表します。ループ回数を 0(ずっとくり返す指定)にした6コマの GIF は Infinity、ループの指定がない3コマの GIF は 0 でした。コンポーネントでは、それぞれ「ずっとくり返す」「くり返さない」と表示しています。
検証した Chromium では、frameCount が 1 の GIF(1コマだけ)でも、animated は true、repetitionCount は Infinity でした。コマが1つだけかどうかは、animated ではなく frameCount で見ると確実です。
once.gif animated true frameCount 3 repetitionCount 0 コマ1 duration 300000
still.gif animated true frameCount 1 repetitionCount Infinity コマ1 duration 0
存在しないコマを指定したときのエラー
6コマの GIF で decode({ frameIndex: 99 }) を呼ぶと、Chromium では RangeError になりました。仕様では、データを読み終えたあとに存在しないコマを指定すると RangeError になると決まっていて、この結果は仕様どおりです。MDN の decode() のページには InvalidStateError だけが書かれていますが、ImageDecoder のページの例は RangeError を受け止める書き方です。エラーの名前で分けるより、completed を待って frameCount の範囲の中だけを指定するほうが確実です。
frameIndex 99: RangeError The frame index provided (99) is outside the range [0, 6).





