Reactで、画面の一部を画像にして保存する方法
画面の一部(カードやグラフの枠など)を PNG にして保存させるには、ref でその要素を受け取り、ライブラリの html-to-image で画像にします。README の仕組みの説明によると、このライブラリは要素を複製して計算済みのスタイルを写し、SVG の <foreignObject> に入れてから canvas に描いて、画像にしています。
ここでは、カードの右上にある「画像で保存」を押すと、カードだけが result.png として保存されるコンポーネントを作ります。保存ボタンを画像から除く方法や、別のオリジンの画像があると失敗する場合も確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、html-to-image 1.11.13、Playwright 1.63.0 です。保存は Playwright でボタンを押し、ダウンロードされたファイルを調べて確かめました。Firefox と Safari、スマートフォンのブラウザ、Web フォントを使った画面、大きな要素(表全体など)は試していません。

要素を PNG にして保存する関数
画像にして保存するところは、React に依存しない関数にまとめました。html-to-image は npm i html-to-image で入れます。
import { toBlob } from 'html-to-image';
// data-capture="skip" を付けた要素(保存ボタンなど)は、画像に入れない
const skipMarked = (node: HTMLElement) => !(node instanceof HTMLElement && node.dataset.capture === 'skip');
// el の見た目を PNG にして、fileName で保存させる
export async function saveElementAsPng(el: HTMLElement, fileName: string) {
const blob = await toBlob(el, {
pixelRatio: 2, // 画面の倍率によらず、2倍の大きさで作る
backgroundColor: '#ffffff', // 指定しないと、背景のない所は透明になる
filter: skipMarked,
});
if (!blob) throw new Error('画像にできませんでした');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
toBlob() は、渡した要素を PNG の Blob にして返します。html-to-image には、data URL を返す toPng() や、canvas を返す toCanvas() もあります。ファイルとして保存するだけなら、Blob が返ってくる toBlob() が扱いやすいです。
保存は、URL.createObjectURL() で Blob を指す URL を作り、download 属性を付けた a 要素をクリックして行います。クリックの直後に URL.revokeObjectURL() で URL を解放していますが、検証環境の Chromium では、保存は途中で止まらずに最後まで終わりました。
filter は、画像に入れる要素を選ぶ関数です。README の filter の説明のとおり、false を返した要素は、その子孫も含めて画像から除かれます。このカードで呼んだところ、要素のほかに文字のノード(#text)も6回渡ってきました。引数の型は HTMLElement ですが、文字のノードには dataset がないため、instanceof HTMLElement で確かめてから読んでいます。画像にする要素そのもの(ここではカードの div)には、filter は呼ばれません。
ref でカードを渡すコンポーネント
ResultCard は、useRef で受け取ったカードの div を saveElementAsPng() に渡します。保存ボタンはカードの中にありますが、data-capture="skip" を付けて、画像には入らないようにしています。
import { useRef, useState } from 'react';
import { saveElementAsPng } from './saveElementAsPng';
export function ResultCard() {
const cardRef = useRef<HTMLDivElement>(null);
const [memo, setMemo] = useState('');
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState('');
async function handleSave() {
if (!cardRef.current) return;
setSaving(true);
setStatus('');
try {
await saveElementAsPng(cardRef.current, 'result.png');
setStatus('result.png を保存しました');
} catch (err) {
setStatus(err instanceof Error ? err.message : '画像にできませんでした');
} finally {
setSaving(false);
}
}
return (
<>
<div ref={cardRef} className="card">
<img src="/cat.png" width={64} height={64} alt="" />
<h2>今週の記録</h2>
<p className="score">歩数 <strong>52,340</strong> 歩</p>
<label>
ひとこと <input value={memo} onChange={(e) => setMemo(e.target.value)} />
</label>
{/* 右上のボタン。data-capture="skip" で画像から除く */}
<button type="button" className="save" data-capture="skip" onClick={handleSave} disabled={saving}>
{saving ? '作成中…' : '画像で保存'}
</button>
</div>
<p role="status">{status}</p>
</>
);
}
DOM の要素を ref で受け取る書き方は、公式ドキュメントの「ref で DOM を操作する」にあります。同じページにあるとおり、ref.current に要素が入るのは React が DOM を更新したあとで、最初のレンダーの間は null です。そのため型も null を含み、handleSave では null でないことを確かめてから使っています。
catch で err instanceof Error を確かめているのは、Error 以外の値で失敗することがあるためです。あとの節で書くとおり、別のオリジンの画像を読めなかったときは、Error ではなく error イベントのオブジェクトで失敗しました。
カードの横幅は 336px(width: 300px に余白と枠を足した幅)で、高さは 239px です。「ひとこと」に「よく歩いた」と入力して保存したところ、672×478 の PNG が result.png として保存されました。PNG では、右上のボタンがあった位置は白くなっており、入力した「よく歩いた」は入力欄の中に写っていました。
保存したファイル名: result.png / 失敗: null / 大きさ: 43744 バイト / PNG 672×478
表示: result.png を保存しました / ボタン: 画像で保存
保存した PNG の、ボタンの中心にあたる位置の色: 255,255,255,255
GIF の中で、カードの下に並べた保存後の画像を見ると、入力欄の枠の描き方は、画面とは少し違いました。文字や色、配置は画面と同じでした。
画像の大きさと背景の色
pixelRatio と backgroundColor を変えて toBlob() を呼び、できた画像の大きさと、左上の角(1, 1)の色を調べました。画面の倍率(Playwright の deviceScaleFactor)も 1 と 2 で変えています。
| オプション | 画面の倍率 1 | 画面の倍率 2 |
|---|---|---|
| なし | 336×239・角は透明 | 672×478・角は透明 |
pixelRatio: 1 |
336×239・角は透明 | 336×239・角は透明 |
pixelRatio: 2 |
672×478・角は透明 | 672×478・角は透明 |
backgroundColor: '#ffffff' |
336×239・角は白 | 672×478・角は白 |
pixelRatio を指定しないと、画像の大きさは画面の倍率で変わりました。README の pixelRatio の説明にも、既定では端末の実際の倍率を使うと書かれています。同じボタンで保存しても、見ている端末によって画像の大きさが変わると困る場合は、pixelRatio を指定して固定します。saveElementAsPng() では 2 にしたので、どちらの倍率でも 672×478 になりました。
カードの角は border-radius で丸めてあるため、角の外側には何も描かれていません。backgroundColor を指定しないと、その部分は透明(RGBA が 0,0,0,0)になりました。透明な部分は、画像を表示する場所の背景の色が透けて見えます。どこに貼られても同じ見た目にしたい場合は、backgroundColor で塗っておきます。
除いたボタンの場所は空いたまま残る
ResultCard の保存ボタンは、CSS の position: absolute でカードの右上に重ねています。検証で使ったカードとボタンの CSS は次のとおりです。
.card { position: relative; width: 300px; padding: 16px; border-radius: 12px; border: 2px solid #00c8e8; background: #fff; }
.card .save { position: absolute; top: 12px; right: 12px; font: inherit; }
ボタンはほかの要素の配置に関わらないので、除いても余白は残りません。画像の高さは、カードの高さと同じ 239px(2倍で 478px)でした。
ボタンを入力欄の下にふつうに並べた(position: static にした)場合も試しました。カードの高さは 281px になり、ボタンは上から 233〜263px の位置にあります。filter で除いた画像も、高さは 281px のままでした。文字や部品が描かれているのは上から 220px の行までで、その下は白い余白です。ボタンを除いても、画像の大きさは元の要素の大きさのままで、空いた場所は詰まりません。
画像に入れたくない部品は、この記事の保存ボタンのように位置を重ねて置くと、余白が残りません。画像にする要素の外に置く方法もあります。その場合、部品は複製されないので filter は要りません(外に置く形は試していません)。
別のオリジンの画像があると、失敗することがある
html-to-image は、要素の中の <img> の画像を fetch で取得し、data URL にして埋め込みます。そのため、別のオリジンにある画像は、そのサーバーが CORS のヘッダー(Access-Control-Allow-Origin)を返さないと取得できません。
ページを 127.0.0.1:5173 から配信し、ポートの違う 127.0.0.1:5174 から同じ猫の画像を配信して、カードの画像を差し替えて試しました。画面の <img> は、どの場合も表示されていました。
| カードの画像 | toBlob() の結果 |
|---|---|
| 同じオリジン | 成功。猫の画像も写った |
| 別のオリジン、CORS のヘッダーあり | 成功。猫の画像も写った |
| 別のオリジン、CORS のヘッダーなし | 失敗(img の error イベントで reject された) |
別のオリジン、CORS のヘッダーなし、imagePlaceholder あり |
成功。画像の場所は imagePlaceholder の灰色になった |
CORS のヘッダーがないと、コンソールに CORS のエラーが出たあと、toBlob() は error イベントのオブジェクトで reject されました。README の imagePlaceholder の説明には「既定では、取得できなかった画像は空の場所になる」とありますが、検証環境では画像全体が作れませんでした。画面の「画像で保存」を押した場合は、ダウンロードはされず、ResultCard には「画像にできませんでした」と表示されました。
imagePlaceholder に data URL を渡しておくと、取得できなかった画像の代わりにそれが使われ、画像は作れました。ここでは灰色(#9aa5ab)1ピクセルの PNG を渡し、画像の場所が灰色になりました。
同じページの中で一度失敗した画像の URL は、あとから imagePlaceholder を付けて toBlob() を呼び直しても、また失敗しました。html-to-image は、取得した結果を URL ごとに覚えておく作りで、取得に失敗したときも、そのとき使った値(imagePlaceholder がなければ空の文字列)を覚えます(html-to-image の dataurl.ts)。imagePlaceholder は、最初に呼ぶときから指定しておきます。
別のオリジンの画像を写したいときは、画像を配信するサーバーで CORS のヘッダーを返すようにするか、同じオリジンから配信します。画面の <img> が表示されていても、画像にできるとは限らない点に注意してください。





