navigator.clipboardでコピーボタンを作る:HTTPで動かない罠と権限の対処
コピーボタンが社内検証のスマホだけ無反応だった僕の失敗から、navigator.clipboard.writeTextの使い方、HTTPSと権限の制約、古いexecCommandへの退避、Reactでの成功表示まで実装で解説します。
「コピーボタン付けといて」と頼んで、できあがったボタンを自分のPCで押したら、ちゃんと動きました。やった、完成。そう思って、社内検証用のスマホで開いて押したら——何も起きませんでした。
エラーも出ない。コピーされた感触もない。トーストすら出ない。バグだと思って小一時間ログを眺めて、ようやく気づきました。そのスマホは http://192.168.x.x でアクセスしていて、navigator.clipboard がそもそも存在していなかったんです。
これがClipboard APIの厄介なところです。HTTPSの自分のPCでは一発で動くのに、HTTPや古いブラウザでは「無」になる。 しかも無反応なので、壊れていることに気づきにくい。今日は、このコピーボタンを「環境によって死んでいる」状態から、どこで開いても必ず何か反応する状態まで持っていきます。
この記事の要点
navigator.clipboard.writeText()はHTTPS(またはlocalhost)でしか動かない。HTTPの社内検証ではnavigator.clipboard自体がundefinedになる。- コピーはユーザーのクリックの中で呼ぶ。
await fetch()を挟んで数秒後にコピーすると、ブラウザが拒否することがある。 - 未対応環境には古い
document.execCommand("copy")で退避する。ダサいAPIだが「最後の砦」として価値がある。 - 成功・失敗は見た目のトーストだけでなく
aria-liveで読み上げにも伝える。色だけで成功を表さない。 - 読み取り(
readText)はコピーより制約がきつい。ページ読み込み時に勝手に読まない、を鉄則にする。 - 共有ボタンを一緒に作るなら、未対応端末はコピーに逃がすnavigator.shareで共有ボタンを作るが相方になる。
Clipboard APIって、結局どういう仕組み?
Clipboard APIは「ブラウザからOSのクリップボード(コピー&ペーストの保管場所)へ読み書きするためのWeb API」です。
イメージしやすいのは、共有の引き出し。あなたのアプリも、メモ帳も、メールも、みんな同じ引き出しに物を出し入れしています。だからブラウザがここに勝手にアクセスできると、ちょっと怖い。住所もパスワードも社内URLも、その引き出しに入っているかもしれないからです。
そこでブラウザは、書き込み(コピー)と読み取り(ペースト)に強めの制約をかけています。覚えておくべきは3つだけです。
- HTTPSが必須。安全な実行環境(secure context = HTTPSや
localhost)でしかnavigator.clipboardは使えません。 - ユーザー操作が必須。クリックやタップの「すぐ後」でないと、コピーが拒否されることがあります。
- 書き込みより読み取りが厳しい。
readText()は権限ダイアログが出たり、そもそも黙って拒否されたりします。
navigator.clipboard.writeText() を呼ぶだけ、に見えて、実務でハマるのは全部この3つの制約のどれかです。順番に潰していきます。
まずClaude Codeに「成功条件」で頼む
僕は最初、Claude Codeに「コピーできるボタンを作って」と雑に頼んでいました。出てきたのは、HTTPSの前提で writeText を呼ぶだけの、退避もエラー表示もないボタン。自分のPCでは動くから、レビューでも見逃しやすいんです。
Clipboard APIはブラウザ差が激しいので、「何ができたら成功か」を先に渡すほうが安全です。成功・失敗・退避・テストを分けて指示します。
Goal: Reactでクリップボードのコピー/ペーストUXを実装する。
Scope: src/lib/clipboard.ts、src/components/CopyButton.tsx、対応するテストだけを編集。
Requirements:
- secure context では navigator.clipboard.writeText を使う。
- writeText の呼び出しはユーザーのクリックハンドラの中に置く。
- 未対応 or HTTP ページ用に textarea フォールバックを用意する。
- ページ読み込み時にクリップボードを読み取らない。
- コピー成功/失敗を、読み上げにも伝わる形で表示する。
Do not stage, commit, or edit unrelated files.
ポイントは、読み取りの自動化を最初に禁止しておくことです。コピーは比較的軽い操作ですが、読み取りは引き出しの中身を覗く操作。必ず「ペースト」ボタンや入力欄の貼り付けイベントなど、ユーザーがはっきり指示したときだけに寄せます。
コピー処理をユーティリティに切り出す
いきなりボタンの中に navigator.clipboard.writeText を書くと、退避処理・エラー文言・テスト用の差し替えがUIのあちこちに散らばります。後で必ず後悔します。
なのでReactから独立した copyText を先に作ります。これが今日の主役で、コピペでそのまま動きます。日本語コメント付きです。
// src/lib/clipboard.ts
// 戻り値は「成功か失敗か」と「どの経路で処理したか」をセットで返す。
// 経路がわかると、テストやログで「退避に落ちた」ことを後から追える。
export type CopyResult =
| { ok: true; method: "async-clipboard" | "textarea-fallback" }
| { ok: false; method: "async-clipboard" | "textarea-fallback" | "unsupported"; error: string };
export async function copyText(text: string): Promise<CopyResult> {
// 空文字をコピーしても無意味なので、ここで弾く。
if (!text) {
return { ok: false, method: "unsupported", error: "コピーする文字列が空です。" };
}
// 本命:HTTPSかつ navigator.clipboard が使えるときだけ writeText を試す。
if (canUseAsyncClipboard()) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: "async-clipboard" };
} catch (error) {
// 権限拒否などで失敗したら、古い execCommand に逃がす。
if (fallbackCopyText(text)) {
return { ok: true, method: "textarea-fallback" };
}
return {
ok: false,
method: "async-clipboard",
error: error instanceof Error ? error.message : "クリップボードへの書き込みが拒否されました。",
};
}
}
// navigator.clipboard が無い環境(HTTP・古いWebView)も、まず退避を試す。
if (fallbackCopyText(text)) {
return { ok: true, method: "textarea-fallback" };
}
return {
ok: false,
method: "unsupported",
error: "このブラウザ・環境ではクリップボードが使えません。",
};
}
// 門番:HTTPSで、navigator.clipboard.writeText が存在するときだけ true。
function canUseAsyncClipboard(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
typeof navigator !== "undefined" &&
Boolean(navigator.clipboard?.writeText)
);
}
// 最後の砦:画面外に textarea を置いて選択し、古い execCommand("copy") を叩く。
function fallbackCopyText(text: string): boolean {
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px"; // 画面の外に飛ばして見せない
textarea.style.opacity = "0";
// ユーザーが選択中だった範囲を覚えておき、後で元に戻す(選択を奪わない)。
const selection = document.getSelection();
const selectedRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
if (selection && selectedRange) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
}
document.execCommand("copy") は古いAPIで、MDNでも非推奨扱いです。新規実装の本命にするものではありません。でも、HTTPページ・古いWebView・制限のきついブラウザでは、これが「最後の砦」として効きます。冒頭の“スマホで無反応”は、まさにこの退避がなかったから起きた事故でした。
ひとつ注意。退避もユーザー操作の中でないと失敗します。クリック後に非同期でデータを取りに行き、数秒経ってからコピーする設計は、本命も退避も両方コケます。
React hookとCopyButtonにまとめる
copyText ができたら、UI側は状態(押下中・成功・失敗)を持つだけです。状態管理はhookに寄せておくと、ボタンの見た目を変えても中身を使い回せます。
// src/components/CopyButton.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { copyText, type CopyResult } from "../lib/clipboard";
type ClipboardStatus = "idle" | "copying" | "copied" | "failed";
export function useClipboard(resetAfter = 2000) {
const [status, setStatus] = useState<ClipboardStatus>("idle");
const [message, setMessage] = useState("");
const timerRef = useRef<number | null>(null);
// アンマウント時にタイマーを片付けておく(消し忘れの警告を防ぐ)。
useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, []);
const copy = useCallback(
async (text: string): Promise<CopyResult> => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setStatus("copying");
setMessage("コピー中…");
const result = await copyText(text);
if (result.ok) {
setStatus("copied");
setMessage("クリップボードにコピーしました。");
} else {
// 失敗時は「次にどうすればいいか」まで言う。
setStatus("failed");
setMessage("コピーに失敗しました。テキストを選択して手動でコピーしてください。");
}
// 一定時間で表示を元に戻す。
timerRef.current = window.setTimeout(() => {
setStatus("idle");
setMessage("");
}, resetAfter);
return result;
},
[resetAfter],
);
return { copy, status, message };
}
type CopyButtonProps = {
text: string;
label?: string;
copiedLabel?: string;
className?: string;
};
export function CopyButton({
text,
label = "コピー",
copiedLabel = "コピー済み",
className = "",
}: CopyButtonProps) {
const { copy, status, message } = useClipboard();
const isCopying = status === "copying";
return (
<div className="inline-flex items-center gap-2">
<button
type="button"
className={className}
onClick={() => void copy(text)}
disabled={isCopying}
aria-label={status === "copied" ? copiedLabel : label}
>
{status === "copied" ? copiedLabel : label}
</button>
{/* 画面の変化を読み上げに伝える領域。見た目には出さない。 */}
<span role="status" aria-live="polite" className="sr-only">
{message}
</span>
</div>
);
}
ここで地味に大事なのが aria-live です。これは「画面上の変化を音声読み上げに知らせる領域」のこと。見えるトーストだけに頼ると、キーボードやスクリーンリーダーで使っている人には「コピーできた」が一切伝わりません。ボタンの文言を変えるのとは別に、状態通知を分けておく。これだけでテストもしやすくなります。
コピーボタンのUX:成功表示で地味にやらかす
実務で一番多いのは、ドキュメントやブログのコードブロックに付けるコピーボタンです。そして僕がClaudeCodeLabの記事で最初にやらかしたのも、ここでした。
何をやらかしたか。コピー成功でボタンの文言が「コピー」→「コピーしました!」に伸びて、ボタン幅が変わり、コードの行が横にガタッとずれたんです。一瞬の出来事だけど、押すたびに画面が動くのは気持ち悪い。
直し方はシンプルで、ボタンに最小幅を決めて、文言が変わっても幅が動かないようにするだけです。min-w-24 のような最小幅を一行足す。これだけ。
// src/components/CodeBlockWithCopy.tsx
import { CopyButton } from "./CopyButton";
type CodeBlockWithCopyProps = {
code: string;
language?: string;
};
export function CodeBlockWithCopy({ code, language = "text" }: CodeBlockWithCopyProps) {
return (
<figure className="relative my-6 overflow-hidden rounded-md border border-slate-700 bg-slate-950">
<figcaption className="flex min-h-10 items-center justify-between border-b border-slate-800 px-3 text-xs text-slate-300">
<span>{language}</span>
<CopyButton
text={code}
label="コードをコピー"
copiedLabel="コピー済み"
// min-w-24 で最小幅を固定。文言が変わっても幅が動かず、行がずれない。
className="min-w-24 rounded bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 disabled:opacity-60"
/>
</figcaption>
<pre tabIndex={0} className="overflow-x-auto p-4 text-sm leading-6">
<code>{code}</code>
</pre>
</figure>
);
}
成功表示には、もう一つコツがあります。色だけで成功を表さないこと。緑になったから成功、は色覚特性によっては伝わりません。文言(コピー済み)とアイコン、あるいは読み上げを合わせる。CopyButton は文言を切り替えつつ aria-live でも通知しているので、見た目に頼りすぎていません。
このボタンは「コードブロック」「招待URL」「注文ID」「CLIコマンド」など、対象を差し替えるだけで使い回せます。Claude Codeに頼むときも、コードブロック専用にせず「汎用コンポーネント+利用例」で分けて依頼すると再利用が効きます。
リッチコンテンツのコピー:ClipboardItem
テキストだけでなく、HTMLや画像をコピーしたい場面もあります。たとえば「整形済みの表をそのままGoogleドキュメントに貼れるようにしたい」とき。ここで ClipboardItem の出番です。
navigator.clipboard.write()(writeText ではなく write)に ClipboardItem を渡すと、同じ内容を複数のMIMEタイプで同時に置けます。貼り付け先がリッチテキスト対応ならHTMLを、プレーンテキストしか扱えないなら text/plain を、と相手が選んでくれます。
// src/lib/copyRich.ts
// プレーンテキストとHTMLを同時にクリップボードへ置く。
// 貼り付け先が対応する方を勝手に選んでくれる。
export async function copyRich(plain: string, html: string): Promise<boolean> {
// ClipboardItem に対応していない環境ではテキストだけにフォールバック。
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
try {
await navigator.clipboard.writeText(plain);
return true;
} catch {
return false;
}
}
const item = new ClipboardItem({
"text/plain": new Blob([plain], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" }),
});
try {
await navigator.clipboard.write([item]);
return true;
} catch {
// 権限拒否や未対応MIMEのときはテキストに逃がす。
try {
await navigator.clipboard.writeText(plain);
return true;
} catch {
return false;
}
}
}
ただし ClipboardItem はブラウザ差が大きく、対応MIMEタイプも限られます。画像(image/png)のコピーはSafariで挙動が違ったりするので、重要な画面は実機で確認してください。基本は writeText で足り、リッチが本当に要るときだけ write を足す、という温度感がちょうどいいです。
ペーストは「読み取り」より貼り付けイベントを優先する
navigator.clipboard.readText() は便利ですが、引き出しの中身を覗く操作なので制約が強い。フォーム入力では、まず通常の貼り付けイベント(onPaste)を使い、どうしても要るときだけ明示的な「クリップボードから貼り付け」ボタンを足します。
そして、貼り付けられた中身は信頼しません。CSV・URL・プロンプト・HTML断片など、何が来るかわからないからです。長さ制限・改行の正規化・NULL文字の除去くらいは最低限かけます。
// src/components/PasteImportBox.tsx
import { useState } from "react";
// 貼り付けテキストを安全な形に整える。
export function normalizePastedText(input: string): string {
return input
.replace(/\r\n?/g, "\n") // 改行コードを LF に統一
.replace(//g, "") // NULL文字を除去
.slice(0, 10_000); // 長さの上限を決める
}
export function PasteImportBox() {
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
async function pasteFromClipboard() {
// readText が無い or HTTP なら、ブラウザの貼り付けショートカットへ誘導。
if (!navigator.clipboard?.readText || !window.isSecureContext) {
setMessage("Ctrl+V / Cmd+V で貼り付けてください。");
return;
}
try {
const text = await navigator.clipboard.readText();
setValue(normalizePastedText(text));
setMessage("クリップボードから貼り付けました。");
} catch {
setMessage("貼り付けが拒否されました。Ctrl+V / Cmd+V を使ってください。");
}
}
return (
<section aria-labelledby="paste-import-title">
<h2 id="paste-import-title">プロンプトを取り込む</h2>
<button type="button" onClick={pasteFromClipboard}>
クリップボードから貼り付け
</button>
<textarea
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onPaste={(event) => {
// 通常の貼り付けはここで受ける。readText の権限ダイアログが要らない。
const text = event.clipboardData.getData("text/plain");
if (!text) return;
event.preventDefault();
setValue(normalizePastedText(text));
setMessage("貼り付けたテキストを正規化しました。");
}}
aria-describedby="paste-import-help"
/>
<p id="paste-import-help" role="status" aria-live="polite">
{message}
</p>
</section>
);
}
HTMLとして受け取って表示するなら、DOMPurifyのようなサニタイザを通してから扱い、dangerouslySetInnerHTML に生のまま渡さないこと。引き出しから取り出したものは、洗ってから使う、くらいの気持ちでちょうどいいです。
どこで死ぬか:HTTP・iframe・Safariの落とし穴
| 環境 | 何が起きるか | 対処 |
|---|---|---|
HTTP(社内検証の http://192.168.x.x 等) | navigator.clipboard が undefined。本命が丸ごと死ぬ | 退避(execCommand)を試し、ダメなら手動コピー案内 |
| iframe内 | Chromium系で親の権限設定が要ることがある | allow="clipboard-write" を付ける |
| Safari / iOS | ユーザー操作との結びつきが特にシビア | クリックの中で即コピー。前に await を挟まない |
| 古いWebView | 本命も退避も不安定 | 失敗を前提に「選択して手動でコピー」を必ず用意 |
冒頭の事故は、表の一行目です。HTTPSの自分のPCでは動くから、HTTP環境で死ぬことに本番直前まで気づけませんでした。window.isSecureContext を見るクセを付けてから、この手のヒヤッとは消えました。
iframeで使うなら、親側で許可を渡します。
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="ドキュメントのプレビュー"
></iframe>
Safari・iOSのWebKitでは、コピーも読み取りも「タップの中で呼ぶ」のが一番安定します。コピー前に await fetch(...) や長いアニメーションを挟むと、急に拒否される。モバイルSafari全体を一言で断定はできないので、重要な画面は実機確認が結局いちばん早いです。
よくある質問
Q. navigator.clipboard.writeText が undefined です。なぜ?
ほぼHTTPでアクセスしているのが原因です。Clipboard APIはHTTPS(または localhost)でしか有効になりません。window.isSecureContext が false になっていないか確認し、未対応なら execCommand 退避と手動コピー案内に逃がしてください。
Q. コピーは権限ダイアログが要りますか?
書き込み(writeText)は、ユーザーのクリックの中で呼べば基本ダイアログなしで通ります。厳しいのは読み取り(readText)のほうで、こちらは権限を求められたり黙って拒否されたりします。だからペーストは onPaste を優先します。
Q. iPhoneのSafariでだけコピーされません。
コピーを呼ぶ前に非同期処理(await fetch など)やアニメーションを挟んでいませんか。WebKitはユーザー操作との結びつきが特にシビアで、タップの中で即 writeText を呼ぶと安定します。実機で確認するのが確実です。
Q. execCommand("copy") は非推奨ですよね。使っていいの?
新規の本命にはしないでください。ただしHTTPページや古い環境向けの「最後の砦」としては今も有効です。本命の writeText を先に試し、ダメなときだけ退避する、という二段構えにするのが現実的です。
Q. 画像やリッチテキストをコピーしたいときは?
writeText ではなく navigator.clipboard.write() に ClipboardItem を渡します。text/html と text/plain を同時に置けば、貼り付け先が対応する方を選びます。ただしブラウザ差が大きいので、未対応時はテキストへフォールバックします。
この記事で紹介した内容を実際に試した結果
冒頭の“スマホで無反応”以来、僕はコピーボタンを「動いた/動かない」で見るのをやめました。代わりに見るのは、どの経路で処理されたかです。CopyResult に method(本命か退避か)を持たせたら、HTTP環境で退避に落ちていることがログですぐ分かるようになりました。
最初の失敗は、成功時にボタン文言が伸びて行がずれること。次が、HTTPSでは成功するのに社内スマホのHTTPで丸ごと死ぬこと。最終的に、コピー処理をユーティリティ化して、退避と手動コピー案内を足し、aria-live で読み上げにも伝える形に落ち着きました。Clipboard APIは小さな機能ですが、HTTPS・権限・アクセシビリティを一緒に設計すると、記事にもプロダクトにも安心して置ける部品になります。
共有ボタンも一緒に作るなら、未対応端末をコピーに逃がすnavigator.shareで共有ボタンを作るが相方です。入力値の扱いまで詰めたい人はフォームバリデーションの実装、テストの安定化はPlaywrightテストの書き方も参考にどうぞ。公式の挙動はMDNのClipboard APIで必ず裏取りしてください。こうした小さなWeb API実装をレビューまで含めて鍛えたい場合は、ClaudeCodeLabの教材も用意しています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。