Tips & Tricks (更新: 2026/6/2)

Claude CodeでClipboard APIを実装: コピーボタン、権限、fallback、テストまで

Clipboard APIをClaude Codeで安全に実装。コピー、ペースト、権限、fallback、React、Playwrightまで実例解説。

Claude CodeでClipboard APIを実装: コピーボタン、権限、fallback、テストまで

Clipboard APIの実装は「ボタンを押したらnavigator.clipboard.writeText()を呼ぶ」だけに見えます。ところが実務では、HTTPSでない環境、iPhone Safari、iframe、権限ダイアログ、コピー失敗時の表示、ペースト時の個人情報、テストの安定性まで考えないと、読者や顧客が使う画面で急に壊れます。

この記事では、Claude Codeに依頼しながら、React/TypeScriptでそのまま使えるコピー・ペースト機能を作ります。Clipboard APIは「ブラウザからOSのクリップボードへ読み書きするWeb API」です。async clipboardはPromiseで結果を待つ非同期版、secure contextはHTTPSやlocalhostのように安全とみなされる実行環境、fallbackは新しいAPIが使えない場合の代替処理、と覚えてください。

関連して、UIの確認はClaude Codeでアクセシビリティを改善する、E2Eの考え方はClaude CodeでPlaywrightテストを書く、入力値の扱いはClaude Codeでフォームバリデーションを実装するも参考になります。公式情報はMDN Clipboard APIMDN writeTextW3C Clipboard API and eventsPlaywright BrowserContextWebKit Async Clipboard APIClaude Code docsを確認してください。

まず実装方針を決める

Claude Codeには、最初から「コピーできるボタンを作って」ではなく、成功条件を渡します。Clipboard APIはブラウザ差があるため、成功・失敗・代替・テストを分けて依頼するほうが安全です。

Goal: Implement Clipboard API copy and paste UX in React.
Scope: edit only src/lib/clipboard.ts, src/components/CopyButton.tsx, and matching tests.
Requirements:
- Use navigator.clipboard.writeText in secure contexts.
- Keep the write call inside a user click handler.
- Provide a textarea fallback for unsupported or HTTP pages.
- Never read clipboard on page load.
- Show accessible copied/error feedback.
- Add Playwright tests for copy success and paste normalization.
Do not stage, commit, or edit unrelated files.

全体像は次の流れです。

flowchart TD
  A["ユーザーがコピーをクリック"] --> B{"navigator.clipboardが使えるか"}
  B -->|yes| C["writeTextでコピー"]
  B -->|no| D["textarea + execCommand fallback"]
  C --> E{"成功したか"}
  D --> E
  E -->|yes| F["aria-liveでコピー済みを通知"]
  E -->|no| G["手動コピー案内を表示"]
  H["ユーザーがペーストを明示操作"] --> I["readTextまたはonPaste"]
  I --> J["長さ制限・正規化・検証"]

ポイントは、クリップボード読み取りを自動化しないことです。読者のクリップボードにはパスワード、住所、社内URL、顧客データが入っているかもしれません。コピーは比較的軽い操作ですが、読み取りは必ず「ペースト」ボタンや入力欄のonPasteなど、ユーザーの明示的な操作に寄せます。

コピー処理をユーティリティに分ける

まずReactから独立したcopyTextを作ります。UI部品に直接navigator.clipboard.writeTextを書くと、fallback、エラー文言、テストの差し替えが散らばります。

// 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: "Copy text is empty." };
  }

  if (canUseAsyncClipboard()) {
    try {
      await navigator.clipboard.writeText(text);
      return { ok: true, method: "async-clipboard" };
    } catch (error) {
      const fallback = fallbackCopyText(text);
      if (fallback) return { ok: true, method: "textarea-fallback" };

      return {
        ok: false,
        method: "async-clipboard",
        error: error instanceof Error ? error.message : "Clipboard write was blocked.",
      };
    }
  }

  if (fallbackCopyText(text)) {
    return { ok: true, method: "textarea-fallback" };
  }

  return {
    ok: false,
    method: "unsupported",
    error: "Clipboard API is unavailable in this browser or context.",
  };
}

function canUseAsyncClipboard(): boolean {
  return (
    typeof window !== "undefined" &&
    window.isSecureContext &&
    typeof navigator !== "undefined" &&
    Boolean(navigator.clipboard?.writeText)
  );
}

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です。積極的に使うものではありませんが、HTTPページ、古いWebView、制限されたブラウザで「最後の手段」として残す価値があります。ただしfallbackもユーザー操作中でないと失敗することがあります。つまり、クリック後に非同期でデータを取りに行き、数秒後にコピーする設計は避けます。

React hookとCopyButtonを作る

次に状態管理を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("Copying...");

      const result = await copyText(text);

      if (result.ok) {
        setStatus("copied");
        setMessage("Copied to clipboard.");
      } else {
        setStatus("failed");
        setMessage("Copy failed. Select the text and copy it manually.");
      }

      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 = "Copy",
  copiedLabel = "Copied",
  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

実務で一番多いユースケースは、ドキュメントやブログのコードブロックです。MasaがClaudeCodeLabの記事で最初に失敗したのは、コピー後にボタン幅が変わってコード行が横にずれる実装でした。ボタンの幅、preの余白、フォーカス表示を固定しておくと崩れにくくなります。

// 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="Copy code"
          copiedLabel="Copied"
          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は「コードブロック」「招待URL」「注文ID」「CLIコマンド」など複数の場所で使えます。Claude Codeに実装させるときは、コードブロックだけに閉じず、汎用コンポーネントと利用例を分けて依頼すると再利用しやすくなります。

ペースト処理は読み取りよりonPasteを優先する

navigator.clipboard.readText()は便利ですが、読み取りはプライバシー上の制約が強い操作です。フォーム入力では、まず通常の貼り付けイベントonPasteを使い、必要なときだけ明示的な「クリップボードから貼り付け」ボタンを用意します。

// src/components/PasteImportBox.tsx
import { useState } from "react";

export function normalizePastedText(input: string): string {
  return input
    .replace(/\r\n?/g, "\n")
    .replace(/\u0000/g, "")
    .slice(0, 10_000);
}

export function PasteImportBox() {
  const [value, setValue] = useState("");
  const [message, setMessage] = useState("");

  async function pasteFromClipboard() {
    if (!navigator.clipboard?.readText || !window.isSecureContext) {
      setMessage("Use your browser paste shortcut instead.");
      return;
    }

    try {
      const text = await navigator.clipboard.readText();
      setValue(normalizePastedText(text));
      setMessage("Pasted from clipboard.");
    } catch {
      setMessage("Paste was blocked. Use Ctrl+V or Cmd+V in the text area.");
    }
  }

  return (
    <section aria-labelledby="paste-import-title">
      <h2 id="paste-import-title">Import prompt</h2>
      <button type="button" onClick={pasteFromClipboard}>
        Paste from clipboard
      </button>
      <textarea
        value={value}
        onChange={(event) => setValue(event.currentTarget.value)}
        onPaste={(event) => {
          const text = event.clipboardData.getData("text/plain");
          if (!text) return;

          event.preventDefault();
          setValue(normalizePastedText(text));
          setMessage("Pasted text was normalized.");
        }}
        aria-describedby="paste-import-help"
      />
      <p id="paste-import-help" role="status" aria-live="polite">
        {message}
      </p>
    </section>
  );
}

ペースト内容は信頼しません。CSV、URL、プロンプト、HTML断片などを受け取る場合でも、長さ制限、改行の正規化、NULL文字の除去、スキーマ検証を入れます。HTMLとして扱うなら、DOMPurifyのようなサニタイザを通し、dangerouslySetInnerHTMLへ直接渡さない設計にします。

3つ以上の実務ユースケース

ユースケース実装ポイント失敗しやすい点
技術記事のコードコピーCopyButtonpreの近くに置き、コピー済み通知を出すボタン幅が変わりレイアウトが揺れる
管理画面の注文IDコピーコピー対象をIDだけに限定し、顧客名や住所を混ぜない表示行全体をコピーして個人情報を含める
サポート窓口のログ貼り付けonPasteで長さ制限と機密語の検出を入れるトークンやCookieを無制限に保存する
招待リンクの共有期限付きURLをコピーし、有効期限も近くに表示する古いリンクをコピーして失効後に問い合わせが増える

ClaudeCodeLabの商品導線でも同じ考え方が使えます。無料PDFのコマンド、研修用のセットアップ手順、個別相談で使う診断テンプレートは、読者が迷わずコピーできると成果につながります。一方で、ライセンスキーや購入者メールをまとめてコピーさせるUIは避けるべきです。

HTTP、iframe、Safariでの落とし穴

Clipboard APIは安全な環境で使う前提です。https://localhostでは動くのに、社内検証用のhttp://192.168.x.xではnavigator.clipboardが存在しないことがあります。その場合はfallbackを試し、失敗したら手動コピーを案内します。

iframe内で使う場合、Chromium系では親側のPermissions Policyやallow属性が必要になることがあります。

<iframe
  src="https://docs.example.com/embed"
  allow="clipboard-read; clipboard-write"
  title="Documentation preview"
></iframe>

SafariやiOSのWebKitでは、コピー・読み取りともにユーザー操作との結びつきが特に重要です。クリックやタップの中でコピーを呼び、コピー前にawait fetch(...)や長いアニメーションを挟まないほうが安定します。モバイルSafari全体を一言で断定せず、重要な画面は実機で確認してください。

Playwrightでコピーをテストする

クリップボードのテストは、ブラウザ権限とoriginを明示します。Playwright公式ドキュメントも、permission名の対応はブラウザやバージョンで変わる可能性があると注意しています。全ブラウザで同じテストに固執せず、Chromiumで主経路、WebKitでUI状態、という分け方も現実的です。

// tests/clipboard.spec.ts
import { expect, test } from "@playwright/test";

const baseURL = "http://127.0.0.1:4173";

test.describe("clipboard UX", () => {
  test.beforeEach(async ({ context }) => {
    await context.grantPermissions(["clipboard-read", "clipboard-write"], {
      origin: baseURL,
    });
  });

  test("copies a code block", async ({ page }) => {
    await page.goto(`${baseURL}/docs/install`);

    await page.getByRole("button", { name: /copy code/i }).first().click();

    await expect(page.getByRole("status")).toContainText(/copied/i);
    await expect
      .poll(() => page.evaluate(() => navigator.clipboard.readText()))
      .toContain("npm");
  });

  test("normalizes pasted text", async ({ page }) => {
    await page.goto(`${baseURL}/support/import`);
    await page.evaluate(() => navigator.clipboard.writeText("line1\r\nline2\u0000"));

    await page.getByRole("button", { name: /paste from clipboard/i }).click();

    await expect(page.getByRole("textbox")).toHaveValue("line1\nline2");
  });
});

CIで失敗する場合は、baseURLがHTTPSではない、権限を付与したoriginとpage.gotoのoriginが違う、ヘッドレスWebKitで期待通りに読めない、ボタン名が翻訳で変わった、などを疑います。

アクセシビリティチェックリスト

  • コピー操作はbuttonで実装し、divクリックにしない。
  • 成功・失敗はrole="status"aria-live="polite"で通知する。
  • ボタンのラベルは「Copy」だけでなく「Copy code」「Copy invite link」のように対象を含める。
  • キーボードフォーカスが見えるようにfocus:ringやoutlineを残す。
  • コピー後にレイアウトが動かないように最小幅を決める。
  • 失敗時は「手動で選択してコピーしてください」のように次の行動を示す。
  • 色だけで成功状態を表さず、文言も変える。
  • ペースト読み取りは明示的な操作に限定し、自動実行しない。

Claude Codeにレビューさせる観点

実装後は、Claude Codeへ「セキュリティ、アクセシビリティ、ブラウザ差、テスト」の順にレビューさせます。

Review only clipboard-related changes.
Check:
1. Clipboard read is never triggered on page load.
2. writeText is called from a user action.
3. HTTP or unsupported browser fallback is handled.
4. copied/error feedback is accessible.
5. pasted text is normalized and size-limited.
6. Playwright tests grant permissions for the correct origin.
Return findings with file and line references.

ClaudeCodeLabの研修では、このような小さなWeb API実装を題材に、仕様確認、実装、テスト、レビュー、記事化までを一つのループにします。自社プロダクトに合わせたClaude Code導入、UI実装のレビュー基準、技術記事から教材・相談への導線を整えたい場合は、ClaudeCodeLabの教材Claude Code研修を確認してください。

この記事で紹介した内容を実際に試した結果

Masaが記事用のコードブロックコピーUIで試したところ、最初の失敗は「成功時にボタン文言が長くなって横幅が変わる」ことでした。次に、HTTPSでは成功するのに社内スマホ検証のHTTP URLで失敗しました。最終的には、コピー処理をユーティリティ化し、fallbackと手動コピー案内を入れ、Playwrightではoriginを固定して権限を付与する形が安定しました。Clipboard APIは小さな機能ですが、権限・プライバシー・アクセシビリティを一緒に設計すると、記事にもプロダクトにも安心して使える部品になります。

#Claude Code #Clipboard API #React #Playwright #アクセシビリティ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。