Reactで、印刷用のレイアウトを用意して印刷する方法

​​React​createPortal,CSS,flushSync,Playwright,TypeScript,カスタムフック

React で作った画面を印刷するときは、印刷用のレイアウトを CSS の @media print に書き、ボタンからブラウザの window.print() を呼びます。ヘッダーやボタンを消し、紙の大きさと余白、ページ番号を @page で決めれば、画面とは別のレイアウトで紙に出せます。

ここでは見積書の画面を例に、印刷用のスタイルを1つずつ足していきます。画面に出していない受付票だけを印刷するフックも作り、Chromium が出力した PDF で結果を確かめました。

補足

検証環境は 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 です。印刷の結果は、Playwright の page.pdf() で Chromium に PDF を出力させて調べました。page.pdf() は印刷用の CSS(print)で PDF を作ります。@page の紙の大きさを使わせるため、preferCSSPageSize: true を付けています。印刷ダイアログを開いての印刷、実際のプリンター、Firefox と Safari、スマートフォンのブラウザでは試していません。

例にした見積書の画面

画面には、上にアプリのヘッダーと印刷のボタン、左にメニューがあります。宛名と数量は入力欄で、数量を変えると金額と合計が変わります。品目は51行あり、Chromium で A4 の PDF に出力すると3ページになります。

上に「見積もりツール」のヘッダーと「見積書を印刷」「受付票だけ印刷」のボタン、左に「見積もり一覧」「設定」のメニューがある。右の白い枠に見積書があり、宛名「見本 様」の入力欄と、No.・品名・単価・数量・金額の表が並ぶ。数量は入力欄で、1行目は 3 になっている
画面で見たときの見積書(幅 800px)

コンポーネントは次のとおりです。印刷のときに消したい要素には no-print、画面だけで使う入力欄には screen-only、印刷のときだけ出す文字には print-only のクラスを付けています。usePrintOnly は、受付票だけを印刷するために、後半で作るフックです。

tsx
import { useState } from 'react';
import { usePrintOnly } from './usePrintOnly';

type Item = { id: number; name: string; price: number };
const items: Item[] = Array.from({ length: 51 }, (_, i) => ({
  id: i + 1,
  name: `部品 ${String(i + 1).padStart(2, '0')}`,
  price: 1200 + i * 150,
}));
const yen = (n: number) => `${n.toLocaleString('ja-JP')} 円`;

export function App() {
  const [to, setTo] = useState('〇〇 様');
  const [qty, setQty] = useState<Record<number, number>>({});
  const total = items.reduce((sum, item) => sum + item.price * (qty[item.id] ?? 1), 0);
  const { printOnly, portal } = usePrintOnly();

  return (
    <div className="layout">
      <header className="app-header no-print">
        <span>見積もりツール</span>
        <button onClick={() => window.print()}>見積書を印刷</button>
        <button onClick={() => printOnly(<Slip to={to} total={total} />)}>受付票だけ印刷</button>
      </header>
      <nav className="side-nav no-print">
        <a href="#">見積もり一覧</a>
        <a href="#">設定</a>
      </nav>
      <main className="sheet">
        <h1>見積書</h1>
        <p className="to">
          <input className="screen-only" value={to} onChange={(e) => setTo(e.target.value)} aria-label="宛名" />
          <span className="print-only">{to}</span>
        </p>
        <table>
          <thead>
            <tr><th>No.</th><th>品名</th><th>単価</th><th>数量</th><th>金額</th></tr>
          </thead>
          <tbody>
            {items.map((item) => {
              const n = qty[item.id] ?? 1;
              return (
                <tr key={item.id}>
                  <td>{item.id}</td>
                  <td>{item.name}</td>
                  <td>{yen(item.price)}</td>
                  <td>
                    <input className="screen-only" type="number" min={0} value={n} aria-label={`${item.name}の数量`}
                      onChange={(e) => setQty({ ...qty, [item.id]: Number(e.target.value) })} />
                    <span className="print-only">{n}</span>
                  </td>
                  <td>{yen(item.price * n)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
        <section className="summary">
          <p>合計 <strong>{yen(total)}</strong></p>
          <p>有効期限:発行日から30日</p>
          <p>お支払い方法は<a href="https://example.com/payment">案内ページ</a>をご覧ください。</p>
        </section>
      </main>
      {portal}
    </div>
  );
}

function Slip({ to, total }: { to: string; total: number }) {
  return (
    <div className="slip">
      <h2>受付票</h2>
      <p>{to}</p>
      <p>見積金額 {yen(total)}</p>
    </div>
  );
}

「見積書を印刷」のボタンは、window.print() を呼ぶだけです。window.print() は、いま表示しているページを印刷するダイアログを開きます。MDN によると、ダイアログが開いている間、この呼び出しは戻ってきません。見積書の印刷では、印刷用のレイアウトにするための処理を React の側には書いていません。すべて CSS で切りかえます。

印刷用のスタイルは @media print にまとめる

画面用のスタイル(app.css)はそのままにして、印刷のときだけ効くスタイルを print.css に分けました。どちらも index.html の link 要素で読み込んでいます。@media print の中に書いたスタイルは、印刷するときだけ使われます。

css
/* 印刷のときだけ見せるもの(画面では隠す) */
.print-only { display: none; }

@media print {
  .no-print, .screen-only { display: none; }
  .print-only { display: inline; }
  body { background: #fff; }
  .layout { display: block; min-height: 0; }
  .sheet { margin: 0; padding: 0; box-shadow: none; }
  /* 見出しの行の背景色も印刷する */
  thead th { print-color-adjust: exact; }
  /* 合計の欄の途中でページが分かれないようにする */
  .summary { break-inside: avoid; }
  /* リンク先の URL を紙にも出す */
  .summary a::after { content: "(" attr(href) ")"; }

  /* usePrintOnly で描いた要素があるときは、それ以外を印刷しない */
  body:has(> .print-portal) > :not(.print-portal) { display: none; }
}

@page {
  size: A4;
  margin: 15mm 12mm 18mm;
  @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt; font-family: "Noto Sans CJK JP", sans-serif; }
}

/* usePrintOnly で描いた要素は、画面には出さない */
@media screen { .print-portal { display: none; } }

前半の @media print では、ヘッダーとメニューを消し、画面用のグリッドのレイアウトをやめて、見積書を紙の幅いっぱいに広げています。app.css で body に付けた背景の水色と、見積書の枠の影も消しました。

宛名と数量の入力欄は、そのまま印刷すると入力欄の枠ごと紙に出ます。そこで、入力欄の横に同じ値を書いた span を置き、印刷のときは入力欄を消して span を出しています。値はどちらも同じ state から描くので、画面で数量を変えれば、印刷される数字も変わります。

検証環境で、宛名を「見本 様」、1行目の数量を 3 にして PDF を出力しました。1ページ目の先頭は次のとおりで、ヘッダーとメニューの文字はどのページにも出ていませんでした。入力欄だった宛名と数量は、ただの文字として出ています。

text
[1ページ] 大きさ 595.0 x 841.9 pt(210 x 297 mm)
  最初の3行: 見積書 | 見本 様 | No. 品名 単価 数量 金額
  最後の3行: 24 部品 24 4,650 円 1 4,650 円 | 25 部品 25 4,800 円 1 4,800 円 | 1 / 3
A4 の紙の1ページ目。上に「見積書」と「見本 様」があり、その下に水色の見出しの行が付いた表が25行目まで続く。1行目の数量は入力欄ではなく「3」の文字になっている。ページの下の中央に「1 / 3」と出ている。ヘッダー、ボタン、メニューは出ていない
PDF に出力した1ページ目

紙の大きさ、余白、ページ番号を @page で決める

後半の @page は、印刷するページそのものの設定です。size: A4 で紙の大きさを、margin で上・左右・下の余白を決めています。PDF のページの大きさは 595.0 x 841.9 pt(210 x 297 mm)で、A4 になっていました。

@page の中の @bottom-center は、ページの下の余白の中央に文字を出すための書き方です。counter(page) がいまのページ番号、counter(pages) が全体のページ数になります。PDF の各ページの最後には、「1 / 3」「2 / 3」「3 / 3」と出ていました。

@bottom-center に font-family を指定しないと、ページ番号の文字は、app.css で body に指定した Noto Sans CJK JP になりませんでした。PDF に入ったフォントを調べると、本文の Noto Sans CJK JP のほかに、Liberation Serif が入っていました。@bottom-center の中に font-family: "Noto Sans CJK JP", sans-serif; を足して出力し直すと、Liberation Serif はなくなりました。そのため、上の print.css では @bottom-center に font-family を指定しています。1ページ目の画像で、ページの下の中央に出ている「1 / 3」も、指定した後の出力です。

次の出力の「print.css のまま」は、font-family を足す前の print.css で出力した結果です。

text
print.css のまま: LiberationSerif, NotoSansCJKjp-Bold, NotoSansCJKjp-Regular
@bottom-center に font-family を足した後: NotoSansCJKjp-Regular, NotoSansCJKjp-Bold

背景色は print-color-adjust: exact で残す

表の見出しの行には、画面と同じ薄い水色の背景を付けています。ブラウザは、印刷のときにインクを節約するなどの理由で、背景の画像を省いたり、文字の色を変えたりすることがあります。print-color-adjust: exact を付けると、色を変えずに印刷するようブラウザに伝えられます。ただし MDN によると、ブラウザの色に関するユーザーの設定のほうが優先されるので、必ず効くとは限りません。

PDF を画像にして、見出しの行の「No.」のすぐ左の色を調べました。page.pdf() は、背景を出力しない既定の設定(printBackground: false)のままにしています。

text
--- out/quote.pdf
  見出しの行「No.」の左隣の色: (200, 238, 246)
--- out/quote-no-adjust.pdf
  見出しの行「No.」の左隣の色: (255, 255, 255)

print-color-adjust: exact を付けたほう(上)は、app.css で見出しの行に付けた背景色 #c8eef6 のとおりの色でした。付けなかったほう(下)は白で、背景色は印刷されませんでした。印刷ダイアログの「背景のグラフィック」の設定との関係は、確かめていません。

ページの境目で、見出しの行と合計の欄をどう扱うか

表が2ページ目に続くとき、2ページ目の先頭にも表の見出しの行が出ました。thead に書いた行は、ページが変わるたびに Chromium が繰り返して出しています。そのために書いた CSS はありません。

text
[2ページ] 大きさ 595.0 x 841.9 pt(210 x 297 mm)
  最初の3行: No. 品名 単価 数量 金額 | 26 部品 26 4,950 円 1 4,950 円 | 27 部品 27 5,100 円 1 5,100 円

表の後ろの合計の欄には、break-inside: avoid を付けています。要素の途中でページを分けないように指定するものです。品目が51行のとき、2ページ目の残りは合計の欄が入りきらない高さでした。break-inside: avoid を外した PDF と比べると、次のようになりました。

text
out/quote.pdf 3ページ 合計=3ページ目 有効期限=3ページ目 お支払い方法=3ページ目
out/quote-no-avoid.pdf 3ページ 合計=2ページ目 有効期限=2ページ目 お支払い方法=3ページ目

外したほうは、「合計」と「有効期限」が2ページ目の最後に、「お支払い方法」の行だけが3ページ目に分かれました。付けたほうは、合計の欄がまとめて3ページ目に移りました。なお、表の行は、break-inside を付けていない状態でも、1行が2ページに分かれることはありませんでした。

A4 の紙の3ページ目の上の部分。右寄せで「合計 254,850 円」「有効期限:発行日から30日」「お支払い方法は案内ページ(https://example.com/payment)をご覧ください。」の3行がまとめて出ている
PDF に出力した3ページ目の上の部分

上の画像の3行目のように、リンクは紙では押せないので、::after と attr(href) でリンク先の URL を文字の後ろに出しています。PDF から取り出した文字でも、「案内ページ」の直後に、かっこで囲んだ https://example.com/payment が入っていました。

画面の一部だけを印刷するフック

「受付票だけ印刷」のボタンは、画面に出していない受付票だけを、見積書とは別に印刷します。このために作ったのが usePrintOnly です。

tsx
import { useEffect, useState, type ReactNode } from 'react';
import { createPortal, flushSync } from 'react-dom';

// 渡した要素だけを印刷する。要素は body の直下に描き、印刷が終わったら消す
export function usePrintOnly() {
  const [content, setContent] = useState<ReactNode>(null);

  useEffect(() => {
    const clear = () => setContent(null);
    window.addEventListener('afterprint', clear);
    return () => window.removeEventListener('afterprint', clear);
  }, []);

  const printOnly = (node: ReactNode) => {
    // 印刷ダイアログが開く前に、要素を DOM に入れておく
    flushSync(() => setContent(node));
    window.print();
  };

  const portal = content === null ? null : createPortal(<div className="print-portal">{content}</div>, document.body);
  return { printOnly, portal };
}

印刷したい要素は、createPortal で body の直下に描きます。body の直下に .print-portal があるときは、print.css の body:has(> .print-portal) > :not(.print-portal) が、それ以外の body の子要素(見積書が入った #root)を印刷から外します。.print-portal は、画面では @media screen で隠しています。印刷が始まった後や印刷プレビューを閉じた後に起きる afterprint イベントで、state を null に戻して要素を消します。

受付票の PDF を出力するために、window.print() を何もしない関数に置きかえ、ボタンを押した後も受付票が残るようにして出力しました。ページは1枚だけで、受付票の3行とページ番号だけが出ました。見積書は出ていません。

text
--- out/slip.pdf
ページ数 1
[1ページ] 大きさ 595.0 x 841.9 pt(210 x 297 mm)
  最初の3行: 受付票 | 見本 様 | 見積金額 254,850 円

続けて afterprint イベントを送ると受付票は消え、見積書をもう一度 PDF に出力すると、元の3ページの見積書に戻っていました。

注意

受付票だけを A6 などの小さい紙に印刷できるか、名前付きページ(@page slip { size: A6 } と page: slip)で試しました。page.pdf() で出力すると、紙は A4 のままでした。印刷ダイアログで紙の大きさがどうなるかは、確かめていません。紙の大きさを変えて印刷する機能として使う前に、使うブラウザで実際に印刷して確かめてください。

flushSync で、印刷の前に DOM へ入れておく

printOnly() で state を変えるとき、flushSync で包んでいるのは、window.print() を呼ぶ時点で、受付票がもう DOM に入っている必要があるからです。ヘッドレスの Chromium では印刷ダイアログが開かないので、印刷の前後のイベントで、受付票が DOM にあるかを記録しました。

text
受付票だけ印刷(window.print() そのまま): [
  'beforeprint: 受付票=あり',
  'afterprint: 受付票=あり',
  'afterprint の後: 受付票=なし'
]

印刷が始まるとき(beforeprint)には受付票があり、afterprint の後に消えていました。

書き方を変えた検証用のコンポーネントで、window.print() が呼ばれた時点に受付票があるかも調べました。flushSync を使わずに setContent(node) の直後に window.print() を呼ぶと、受付票は「なし」でした。この状態で印刷が始まると、print.css の :has() の指定に当てはまる要素がないので、見積書のほうが印刷されることになります(印刷ダイアログでは試していません)。setContent(node) だけにして、content が変わったときに useEffect の中で window.print() を呼ぶ書き方では「あり」になり、開発用ビルドの StrictMode の中でも、呼ばれたのは1回でした。

useEffect で呼ぶ書き方は、次のとおりです。

tsx
function useInEffect() {
  const [content, setContent] = useState<ReactNode>(null);
  useEffect(() => {
    if (content === null) return;
    const clear = () => setContent(null);
    window.addEventListener('afterprint', clear);
    window.print();
    return () => window.removeEventListener('afterprint', clear);
  }, [content]);
  return { printOnly: setContent, portal: content === null ? null : createPortal(<div className="print-portal">{content}</div>, document.body) };
}

結果は次のとおりです。v=noflush が flushSync を使わない書き方、v=effect が useEffect で呼ぶ書き方で、&strict は StrictMode の中に置いた場合です。

text
variants.html?v=noflush: window.print() の呼び出し = [ '受付票なし' ]
variants.html?v=effect: window.print() の呼び出し = [ '受付票あり' ]
variants.html?v=effect&strict: window.print() の呼び出し = [ '受付票あり' ]
variants.html?v=noflush&strict: window.print() の呼び出し = [ '受付票なし' ]

flushSync で包む書き方では beforeprint の時点で、useEffect で呼ぶ書き方では window.print() を呼んだ時点で、受付票はもう DOM に入っていました。この記事の usePrintOnly では、ボタンを押したときの処理の中で順番が読み取れるように、flushSync の書き方にしました。

​Posted by 86