技術記事のコードブロックの右上や、シェア用リンクの横に置かれている「コピー」ボタン。押すだけでクリップボードに文字列が入るあの仕組みは、JavaScript の navigator.clipboard で作れます。かつては document.execCommand("copy") という方法が使われていましたが、これは現在では非推奨です。この記事では、いま推奨されている Clipboard API の基本形から、動くコピーボタンの作り方、うまくコピーできないときの原因までを解説します。
目次
navigator.clipboard でできること
navigator.clipboard は、OS のクリップボード(コピー&ペーストの一時保管場所)をブラウザーから操作するための入り口です。ここから、クリップボードに文字列を書き込んだり、逆に中身を読み取ったりできます。ユーザーが「文字列を選択して Ctrl + C を押す」という手間をかけずに、ボタン1つで同じ結果を用意できるのがいちばんの利点です。
以前は隠しテキストエリアに文字列を入れて選択し、document.execCommand("copy") を呼ぶという回りくどい方法が定番でした。この execCommand() は現在 非推奨 とされていて、仕様上もいつ削除されてもおかしくない扱いです。新しく書くコードでは Clipboard API を使ってください。
ただし Clipboard API は「勝手にクリップボードを書き換えられる」と困る性質を持つため、後述するとおり 使える条件がいくつか決まっています。まずは基本形から見ていきます。
writeText() でコピーする基本形
文字列のコピーは navigator.clipboard.writeText() の1行で完了します。引数に渡した文字列がそのままクリップボードに入ります。
const button = document.querySelector("#copy");
button.addEventListener("click", async () => {
await navigator.clipboard.writeText("コピーされる文字列");
console.log("コピーしました");
});
ポイントは writeText() が Promise を返すことです。書き込みは即座に終わるとは限らず、ブラウザーが許可を確認する時間もあるため、結果を待ってから次の処理に進む必要があります。そのため、上のように async 関数の中で await を付けて呼ぶのが基本です。await を使わない書き方なら .then() でも同じことができます。
// .then() で書く場合
navigator.clipboard.writeText("コピーされる文字列")
.then(() => {
console.log("コピーしました");
})
.catch((error) => {
console.error("コピーに失敗しました", error);
});
成功したときの戻り値はありません(undefined が解決されます)。「コピーできたかどうか」は、Promise が解決されたか、それとも失敗(reject)したかで判断します。
Clipboard API の4つのメソッド
navigator.clipboard が持つメソッドは4つです。日常的に使うのは writeText() がほとんどで、残りは用途が限られます。
| メソッド | 働き |
|---|---|
writeText(text) | 文字列をクリップボードに書き込む。もっともよく使う |
readText() | クリップボードの中身を文字列で読み取る。許可が必要 |
write(items) | ClipboardItem の配列を書き込む。画像などテキスト以外も扱える |
read() | クリップボードの中身を ClipboardItem の配列で読み取る |
write() と read() は ClipboardItem というオブジェクトを介してやり取りします。ClipboardItem は「MIME タイプ(text/plain や image/png など)」と「その中身のデータ」を組にしたもので、これにより画像のコピーや、書式付きテキスト(text/html)としての書き込みが可能になります。ただし実際に受け付けられる MIME タイプはブラウザーによって差があるため、テキストだけで済むなら writeText() を使うほうが確実です。
// 画像(Blob)をクリップボードへ書き込む例
async function copyImage() {
const response = await fetch("/images/sample.png");
const blob = await response.blob();
// MIME タイプとデータの組を ClipboardItem にまとめる
const item = new ClipboardItem({ "image/png": blob });
await navigator.clipboard.write([item]); // 引数は配列
}
使えるのは HTTPS か localhost のときだけ
Clipboard API は セキュアコンテキスト(安全なコンテキスト)でのみ利用できる と決められています。具体的には、https:// で配信されているページか、開発中の http://localhost(および 127.0.0.1)です。
この条件を満たさない場合、navigator.clipboard は そもそも存在せず undefined になります。エラーメッセージが「Cannot read properties of undefined」のような形で出るため、一見すると書き方の間違いに見えますが、原因は環境のほうにあります。特に、HTML ファイルをエディターからダブルクリックして file:// で開いて試すと確実に動きません。ローカルで確認するときは、簡易サーバーを立てて http://localhost 経由で開いてください。
もう1つの条件が、ユーザーの操作をきっかけに呼び出すことです。ページを開いた直後や setTimeout() の中から勝手に書き込もうとすると、ブラウザーによっては拒否されます。クリックなどのイベントハンドラーの中から呼ぶ、という原則を守っておけば問題は起きません。
ボタンでコピーする実装例
ここからは、実際によく作る3パターンを見ていきます。
入力欄の値をコピーして表示を切り替える
もっとも基本的な形が、テキスト入力欄の値をコピーするパターンです。コピー自体は input.value を writeText() に渡すだけですが、それだけでは何も起きていないように見えてしまいます。ボタンのラベルを「コピーしました」に変え、数秒後に元へ戻す、という一時的な表示切り替えを添えるのが定番です。
次のデモで実際に動かせます。なお、プレビューは iframe の中で動くため、環境によってはクリップボードへの書き込みが許可されず、エラーメッセージが表示されることがあります。
<div class="compare">
<div class="box">
<p class="label">text-wrap: wrap(初期値)</p>
<h2 class="normal">text-wrap で見出しの改行位置を整える</h2>
<h2 class="normal">Balance the last line of a heading</h2>
</div>
<div class="box">
<p class="label">text-wrap: balance</p>
<h2 class="balanced">text-wrap で見出しの改行位置を整える</h2>
<h2 class="balanced">Balance the last line of a heading</h2>
</div>
</div>
body {
margin: 16px;
font-family: sans-serif;
color: #333;
}
.compare {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
/* わざと幅を狭くして、見出しを折り返させる */
.box {
width: 250px;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
}
.label {
margin: 0 0 8px;
color: #007bff;
font-size: 12px;
}
.box h2 {
margin: 0 0 16px;
font-size: 19px;
line-height: 1.6;
}
.box h2:last-child {
margin-bottom: 0;
}
/* 初期値。あふれが最小になる位置で折り返す */
.normal {
text-wrap: wrap;
}
/* 各行の長さが揃うように折り返し位置を選び直す */
.balanced {
text-wrap: balance;
}
ラベルを戻すタイマーは、連打されたときのために clearTimeout() で前回分を打ち消してから設定し直しています。こうしておかないと、1回目のタイマーが後から発火してラベルが早く戻ってしまいます。
コードブロックの中身をコピーする
技術ブログでよく見かける、コードブロックに付いたコピーボタンです。コピーしたいのは表示されている文字そのものなので、innerHTML ではなく textContent を読み取ります。innerHTML だとシンタックスハイライト用の <span> タグや、エスケープされた < のような文字参照がそのまま混ざってしまいます。
コードブロックはページ内に複数あるのが普通なので、ボタンごとに closest() で自分の属するブロックをたどり、その中の <code> を取得するようにします。こうすればボタンが何個あっても同じ処理で動きます。
<label class="switch">
<input type="checkbox" id="toggle">
カードのタイトルに text-wrap: balance を適用する
</label>
<div class="cards">
<article class="card">
<h3>CSS カスタムプロパティで配色をまとめて管理する</h3>
<p>2026.08.16</p>
</article>
<article class="card">
<h3>Flexbox の gap プロパティ</h3>
<p>2026.08.10</p>
</article>
<article class="card">
<h3>画像の遅延読み込みで表示速度を改善する方法</h3>
<p>2026.08.02</p>
</article>
</div>
body {
margin: 16px;
font-family: sans-serif;
color: #333;
}
.switch {
display: inline-block;
margin-bottom: 16px;
font-size: 13px;
cursor: pointer;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.card {
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
}
.card h3 {
margin: 0 0 8px;
font-size: 16px;
line-height: 1.6;
}
.card p {
margin: 0;
color: #888;
font-size: 12px;
}
/* チェックを入れたときだけタイトルの行を均等にする */
body:has(#toggle:checked) .card h3 {
text-wrap: balance;
}
このデモでは、冒頭で navigator.clipboard の有無を調べ、使えない環境ならボタンを remove() で取り除いています。押しても必ず失敗するボタンを残しておくより、最初から見せないほうが親切です。
今見ているページの URL をコピーする
シェア用のリンクをコピーさせたいときは、location.href をそのまま渡すだけです。SNS のシェアボタンの隣に「リンクをコピー」を置くような使い方でよく登場します。
const shareButton = document.querySelector("#share");
shareButton.addEventListener("click", async () => {
try {
// location.href は今開いているページの URL 全体
await navigator.clipboard.writeText(location.href);
shareButton.textContent = "リンクをコピーしました";
} catch {
shareButton.textContent = "コピーできませんでした";
}
});
トラッキング用のパラメータを外した URL を渡したい、といった場合は、new URL(location.href) で URL オブジェクトを作り、不要なパラメータを削ってから toString() の結果を渡します。渡すのはあくまで文字列である点だけ守れば、中身は自由に組み立てて構いません。
読み取りの readText() は制約が強い
クリップボードの中身を取り出すのが readText() です。Promise が解決されると、その中身が文字列で渡されます。
const pasteButton = document.querySelector("#paste");
const input = document.querySelector("#code-input");
pasteButton.addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
input.value = text;
} catch (error) {
// 許可されなかった場合もここに来る
console.error("読み取れませんでした", error);
}
});
ただし読み取りは、書き込みよりずっと厳しく制限されています。クリップボードにはパスワードや他のサイトからコピーした個人情報が入っている可能性があり、ページが中身を勝手に覗けてしまうと危険だからです。そのため readText() には clipboard-read の許可が必要とされ、ブラウザーによって許可ダイアログが出たり、「貼り付け」を確認するボタンの表示が求められたり、そもそも拒否されたりします。対応状況もブラウザーごとに差が大きい部分です。
Permissions API に対応した環境なら、事前に許可の状態を調べることもできます。state は "granted"(許可済み)・"prompt"(実行時に確認)・"denied"(拒否)のいずれかです。ただし clipboard-read という権限名自体に対応していないブラウザーでは query() がエラーになるため、下のように try...catch で囲んで扱います。
async function checkPermission() {
try {
const status = await navigator.permissions.query({ name: "clipboard-read" });
console.log(status.state); // "granted" / "prompt" / "denied"
} catch {
// この権限名に対応していないブラウザーもある
console.log("許可の状態を確認できませんでした");
}
}
実務では、読み取りを機能の前提にしないほうが安全です。「貼り付けボタン」を用意する場合でも、ユーザーが自分で Ctrl + V(Mac は Command + V)できる入力欄を必ず併せて置いておけば、読み取りが拒否されても操作が行き止まりになりません。
失敗に備える書き方とフォールバック
Clipboard API の呼び出しは、許可されなければ Promise が失敗(reject)します。await で呼んでいるなら、その失敗は例外として投げられるので try...catch で受け止めます。await に try...catch を付けずに書くと、拒否されたときに未処理のエラーになり、その後の処理も止まってしまいます。
そのうえで考えたいのが、navigator.clipboard がそもそも存在しない環境への備えです。判定は単純で、if (!navigator.clipboard) のように有無を確かめるだけです。ここで無理に動かそうとするより、コピー機能そのものを出さないのが最も分かりやすい対処になります。
const button = document.querySelector("#copy");
const input = document.querySelector("#share-url");
// 使えない環境ではボタンを隠す(hidden 属性で非表示にする)
if (!navigator.clipboard) {
button.hidden = true;
} else {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(input.value);
button.textContent = "コピーしました";
} catch (error) {
// 失敗したら手動でコピーしてもらう
input.select();
button.textContent = "手動でコピーしてください";
}
});
}
失敗したときの逃げ道として、上の例では input.select() で入力欄の文字列を選択状態にしています。あとはユーザーが Ctrl + C を押すだけで済むので、コピーできない環境でも操作が完結します。かつてのフォールバックとして document.execCommand("copy") を併用する書き方も広く出回っていますが、非推奨の API である以上、新規の実装で頼りにするのは避けたほうがよいでしょう。
コピーできないときに確認すること
「手元では動くのに本番で動かない」「特定のページだけ失敗する」というときは、次のどれかに当てはまることがほとんどです。
http や file:// で開いている
最初に疑うべきはページの配信方法です。セキュアコンテキストでなければ navigator.clipboard は undefined になるため、writeText is not a function ではなく「undefined のプロパティを読めない」という種類のエラーになります。https でないページ、社内向けの http のテスト環境、HTML ファイルを直接ダブルクリックして開いた file:// がこれに当たります。コンソールで console.log(navigator.clipboard) を実行し、undefined が返るかどうかを確かめれば一発で切り分けられます。
ユーザー操作と関係ないタイミングで呼んでいる
ページの読み込み完了時や setTimeout() の中など、クリックと切り離れたタイミングでの呼び出しは拒否されることがあります。クリックのハンドラーの中であっても、その前に時間のかかる await を挟むと、ユーザー操作の効力が切れたと判断される場合があります。サーバーからデータを取ってきてからコピーしたいようなときは、先に文字列を用意しておいてクリック時にはコピーだけを行う、という順番にすると安定します。
iframe の中から呼んでいる
別ドメインの iframe に埋め込まれたページからの書き込みは、既定では許可されません。埋め込む側の <iframe> タグに allow="clipboard-write" を付けて、権限を明示的に渡す必要があります。CodePen のようなオンラインエディターや、この記事のプレビューのように iframe を使う仕組みで「ローカルでは動いたのに動かない」となる場合は、まずこれを疑ってください。
<!-- 埋め込む側で書き込みの権限を渡す -->
<iframe src="https://example.com/widget/" allow="clipboard-write"></iframe>
ページがフォーカスされていない
クリップボードを操作できるのは、そのページが前面にあるときだけです。別のタブやウィンドウに切り替えた状態で呼び出すと、「Document is not focused」といったメッセージの NotAllowedError になります。開発者ツールのコンソールに直接 navigator.clipboard.writeText("test") と打ち込んで試すと、コンソール側にフォーカスがあるせいでこのエラーが起きることがあります。API が壊れているわけではないので、ページ内のボタンから呼んで確かめ直してください。
まとめ
テキストのコピーは await navigator.clipboard.writeText(文字列) の1行が基本で、非推奨になった document.execCommand("copy") を使う理由はもうありません。押しただけでは変化が見えないので、ボタンのラベルを一時的に「コピーしました」に変える演出まで含めて実装するのがおすすめです。読み取りの readText() は許可が必要で環境差も大きいため、機能の前提にはしないほうが安全でしょう。うまく動かないときは、まず https か localhost で開いているか、クリックのハンドラーから呼んでいるか、この2点を確認してみてください。