Reactで、クリップボードにコピーするボタンを作る方法

​​React​Clipboard API,Playwright,TypeScript,アクセシビリティ,カスタムフック

コマンドや URL の横に置く「コピー」ボタンは、navigator.clipboard.writeText() を呼ぶカスタムフックを1つ作っておけば、どのコンポーネントにも同じ動きで置けます。押したあとに「コピーしました」と出して2秒で元に戻す処理や、コピーできなかったときの表示も、フックの中にまとめられます。

筆者は検証環境で、コマンドの横にコピーボタンを置いた小さな画面を作り、Chromium 系の Google Chrome for Testing を Playwright で操作して、クリップボードに入った内容と表示の変わり方を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Google Chrome for Testing 153 を Playwright 1.56.1 で操作しました。StrictMode は使っていません。Firefox と Safari、スマホのブラウザでは試していません。

ポイント

コピーは navigator.clipboard.writeText() で行い、返ってくる Promise の成否で「コピーしました」と「コピーできませんでした」を出し分けます。安全でない http のページ(localhost や 127.0.0.1 以外)では navigator.clipboard そのものがないので、呼ぶ前に確かめます。表示を戻すタイマーは ref に持ち、押し直したときとアンマウントしたときに止めます。

コピーの処理をカスタムフックにまとめる

検証に使ったフックです。copy(text) を呼ぶとクリップボードに書き込み、結果を status に入れます。status は、何もしていない 'idle'、成功した 'copied'、失敗した 'error' の3つです。

ts
import { useEffect, useRef, useState } from 'react';

export type CopyStatus = 'idle' | 'copied' | 'error';

export function useCopyToClipboard(resetMs = 2000) {
  const [status, setStatus] = useState<CopyStatus>('idle');
  const timerRef = useRef<number | undefined>(undefined);
  const mountedRef = useRef(false);

  // アンマウントしたら、表示を戻すタイマーを止める
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      window.clearTimeout(timerRef.current);
    };
  }, []);

  async function copy(text: string): Promise<boolean> {
    let ok = false;
    try {
      // 安全でない http のページでは navigator.clipboard そのものがない
      if (!navigator.clipboard) throw new Error('Clipboard API が使えません');
      await navigator.clipboard.writeText(text);
      ok = true;
    } catch (e) {
      console.warn('コピーに失敗しました', e);
    }
    // 書き込みを待つあいだに画面から外れていたら、表示もタイマーも触らない
    if (!mountedRef.current) return ok;
    setStatus(ok ? 'copied' : 'error');
    // 前のタイマーを止めてから、一定時間後に表示を戻すタイマーをセットする
    window.clearTimeout(timerRef.current);
    timerRef.current = window.setTimeout(() => setStatus('idle'), resetMs);
    return ok;
  }

  return { status, copy };
}

writeText() は、書き込みが終わると解決する Promise を返し、書き込みが許されないときは NotAllowedError で失敗します。そのため await して try / catch で囲めば、成功と失敗を分けられます。

タイマーの ID は、表示に関係しない値なので useState ではなく useRef に入れます。公式リファレンスの ref で値を持つ例でも、インターバルの ID を ref に入れて、あとで止めるのに使っています。新しいタイマーをセットする直前に前のタイマーを止めるので、続けて押したときは、最後に押してから2秒数え直します。

writeText() は Promise を返すので、書き込みを待つあいだにボタンが画面から外れることもありえます。そのあとでタイマーをセットすると、クリーンアップはもう終わっているので、止める機会がありません。mountedRef に、ボタンが画面にあるかどうかを記録しておき、外れていたら表示もタイマーも触らずに終えます。書き込みの途中で外れる場面そのものは、検証では再現していません。

TypeScript の DOM の型定義では、navigator.clipboard は readonly clipboard: Clipboard; と書かれていて、常にあることになっています。それでも if (!navigator.clipboard) と確かめるコードは型エラーにならず、型チェックを通りました。実際のブラウザでは、後半の「コピーできない場面と、そのときの表示」で見るとおり、undefined になる場合があります。

ボタンの文字と、読み上げ用の表示を切り替える

フックを使うボタンのコンポーネントです。押したあとはボタンの文字を「コピーしました」に変え、その下に結果の文を出します。

tsx
import { useCopyToClipboard } from './useCopyToClipboard';

type Props = { text: string; label?: string };

export function CopyButton({ text, label = 'コピー' }: Props) {
  const { status, copy } = useCopyToClipboard();

  return (
    <>
      <button type="button" onClick={() => copy(text)}>
        {status === 'copied' ? 'コピーしました' : label}
      </button>
      {/* 画面読み上げソフトにも結果を伝える */}
      <span role="status" className="copy-status">
        {status === 'copied' && 'クリップボードにコピーしました'}
        {status === 'error' && 'コピーできませんでした。手動で選択してコピーしてください'}
      </span>
    </>
  );
}

結果の文は role="status" の要素に入れています。MDN の status ロールの説明によると、この要素は、何も指定しなくても aria-live="polite" を付けたのと同じ扱いになります。中身が変わると、画面読み上げソフトは、ユーザーの操作が落ち着くのを待ってから伝えます。MDN の ライブリージョンの解説では、要素を空のまま最初から置いておき、あとから中身を変えるよう勧めています。そのため、このコンポーネントでも <span> はいつも描画し、中の文だけを切り替えています。この検証では、実際の画面読み上げソフトで読み上げを聞いてはいません。

使う側は <CopyButton text="npm install react" /> のように、コピーしたい文字列を渡すだけです。

コマンドの横のコピーボタンを押すと「コピーしました」に変わり、下の入力欄にCtrl+Vで同じコマンドが貼り付けられ、押してから2秒でボタンが「コピー」に戻る様子
コピーを押してから、貼り付けて、ボタンが元に戻るまでの動き

押したときの動きを確かめた結果

コピーボタンを押したあと、readText() で読み戻した内容と、入力欄に Ctrl+V で貼り付けた内容を記録しました。どちらも、ボタンに渡した文字列と同じでした。表示は1.5秒後には「コピーしました」のままで、2.3秒後には「コピー」に戻っています。

text
押した直後: ボタン「コピーしました」 状態「クリップボードにコピーしました」
readText: "npm install react"
textarea に Ctrl+V: "npm install react"
1.5秒後: ボタン「コピーしました」 状態「クリップボードにコピーしました」
2.3秒後: ボタン「コピー」 状態「」

続けて押した場合も試しました。1回押し、1秒後にもう一度押すと、1回目から2.3秒たっても「コピーしました」のままで、3.3秒後には戻っていました。2回目を押した時点からタイマーを数え直しています。

text
1回目から2.3秒後: ボタン「コピーしました」 状態「クリップボードにコピーしました」
1回目から3.3秒後: ボタン「コピー」 状態「」

コピーできない場面と、そのときの表示

クリップボードへの書き込みは、安全なコンテキスト(https のページや、localhost・127.0.0.1 で開いたページ)でしかできません。ふつうのドメインを http で開いたページでは navigator.clipboard が undefined になるので、そのまま writeText() を呼ぶことはできません。フックの中で先に確かめているのは、このためです。

検証環境では、ブラウザの起動オプションで insecure.example というホスト名を手元のサーバーに向け、そのホスト名を http で開きました。isSecureContext は false、navigator.clipboard は undefined になり、ボタンを押すと失敗の表示が出ました。

text
isSecureContext: false  typeof navigator.clipboard: undefined
  [console.warning] コピーに失敗しました Error: Clipboard API が使えません
押した直後: ボタン「コピー」 状態「コピーできませんでした。手動で選択してコピーしてください」

もう1つは、別のオリジンのページに iframe で埋め込まれた場合です。MDN の Clipboard API のセキュリティの説明によると、Chromium 系のブラウザでは、iframe の中でクリップボードを使うには、Permissions Policy の clipboard-write が許されている必要があります。埋め込む側で allow 属性を付けなかった iframe では、writeText() が NotAllowedError で失敗しました。allow="clipboard-write" を付けた iframe では、同じボタンでコピーできました。

text
  [console.warning] コピーに失敗しました NotAllowedError: Failed to execute 'writeText' on 'Clipboard': The Clipboard API has been blocked because of a permissions policy applied to the current document. See https://crbug.com/414348233 for more details.
allow なし: ボタン「コピー」 状態「コピーできませんでした。手動で選択してコピーしてください」
allow="clipboard-write" あり: ボタン「コピーしました」 状態「クリップボードにコピーしました」

ブラウザによる違いもあります。同じ MDN の説明では、Chromium 系は clipboard-write の許可かユーザーの操作の直後であること、Firefox と Safari はユーザーの操作の直後であることを、書き込みの条件にしています。MDN の ユーザー操作(user activation)についての説明では、ボタンのクリックをその代表的な例に挙げています。ボタンの onClick の中で copy を呼ぶのはそのためです。この検証は Chromium 系の Google Chrome for Testing だけで行い、Playwright で書き込みと読み取りの許可を与えた状態で試しました。Firefox と Safari での動きは確かめていません。

注意

失敗したときに何も表示しないと、使う人が、前にコピーした内容をそのまま貼り付けてしまうおそれがあります。失敗の表示を必ず出し、手で選択してコピーできるように、コピーする文字列は画面に見える形で置いておきます。

Clipboard API より前からある document.execCommand('copy') については、MDN の execCommand() のリファレンスでは、このメソッドは非推奨とされていて、クリップボードの操作には Clipboard API が勧められています。この記事のフックでは使っていません。

アンマウントしたときにタイマーを止める

「コピーしました」の表示中にボタンが画面から消えることもあります。useEffect に渡した関数が返すクリーンアップ関数は、コンポーネントが画面から取り除かれたときに React が呼ぶので、そこでタイマーを止めています。

検証では、押した直後にボタンを外し、2.5秒待ちました。待ち時間が1秒以上の setTimeout のコールバックが動いた回数を数えたところ、0回でした。表示を戻すタイマーは、ボタンと一緒に止まっています。

text
押した直後: ボタン「コピーしました」 状態「クリップボードにコピーしました」
ボタンの数: 0
2.5秒後に動いた、表示を戻すタイマーの数: 0

コピーボタンを作るときのポイント

コピーそのものは navigator.clipboard.writeText() の1行で済みます。ボタンとして使いやすくするには、結果を 'copied' と 'error' に分けて表示し、表示を戻すタイマーを ref に持って、押し直しとアンマウントのときに止めます。結果の文は role="status" の要素に入れておくと、画面読み上げソフトにも伝わる作りになります。安全でない http のページや、allow のない iframe の中ではコピーできないので、失敗の表示と、手で選択できる文字列を用意しておきます。

​Posted by 86