Tips & Tricks (Updated: 6/2/2026)

Clipboard API with Claude Code: copy buttons, permissions, fallbacks, and tests

Implement Clipboard API with Claude Code: copy buttons, permissions, fallbacks, paste handling, React, and Playwright.

Clipboard API with Claude Code: copy buttons, permissions, fallbacks, and tests

Clipboard API work looks tiny until it fails in production. A button that calls navigator.clipboard.writeText() may work on your laptop, then break on an HTTP preview URL, inside an iframe, after a delayed fetch, or on a mobile Safari flow where the browser expects a fresh user gesture.

This guide shows how to use Claude Code to build implementation-ready copy and paste UX in React and TypeScript. Clipboard API means the browser API that reads from or writes to the operating system clipboard. Async Clipboard means the Promise-based API. Secure context means HTTPS, localhost, or another origin the browser treats as trustworthy. Fallback means the older path we try when the modern API is unavailable.

For related ClaudeCodeLab articles, pair this with accessibility improvements in Claude Code, Playwright testing with Claude Code, and form validation with Claude Code. Keep the official references open: MDN Clipboard API, MDN writeText, W3C Clipboard API and events, Playwright BrowserContext, WebKit Async Clipboard API, and the Claude Code docs.

Start with acceptance criteria

Do not ask Claude Code for “a copy button” and hope the browser details are handled. Give it the failure modes up front.

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.

The working flow is simple, but every branch matters.

flowchart TD
  A["User clicks copy"] --> B{"Async Clipboard available?"}
  B -->|yes| C["writeText"]
  B -->|no| D["textarea + execCommand fallback"]
  C --> E{"Success?"}
  D --> E
  E -->|yes| F["Announce copied with aria-live"]
  E -->|no| G["Show manual copy guidance"]
  H["User explicitly pastes"] --> I["readText or onPaste"]
  I --> J["Normalize, limit, validate"]

The privacy rule is the most important one: never read a user’s clipboard on page load or as a hidden background action. A clipboard can contain passwords, addresses, private URLs, customer data, or source code. Reads should happen only through a visible paste action or a normal onPaste event.

Build a reusable copy utility

Put the browser-specific work in a small utility before writing UI. That keeps fallbacks, errors, and tests in one place.

// 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") is deprecated, so it should not be your main path. It is still useful as a last-resort fallback for older browsers, restricted webviews, and HTTP previews. It can still fail, especially if the call is not triggered by a user action.

Add a React hook and accessible button

The hook below handles the temporary copied state and exposes one copy function. The component uses role="status" and aria-live so assistive technology can announce success or failure.

// 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>
  );
}

The visible label is not enough. A user who cannot see the toast still needs confirmation that the action completed. The separate status region also gives Playwright a reliable assertion target.

Copy code blocks without layout shift

Documentation and blog code blocks are the common first use case. Masa’s first ClaudeCodeLab implementation had a subtle bug: when the label changed from “Copy” to “Copied”, the button became wider and nudged the code area. A fixed minimum width avoids that.

// 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>
  );
}

The same button can copy a CLI command, invite link, coupon code, support case ID, or generated prompt. Ask Claude Code for a reusable component, then add usage examples instead of hard-coding the code-block case only.

Treat paste as sensitive input

Prefer the browser’s normal onPaste event in text inputs. Use navigator.clipboard.readText() only behind a visible paste button.

// 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>
  );
}

Do not trust pasted content. Limit length, normalize line endings, remove control characters, and validate the expected format. If you accept HTML, sanitize it before rendering and never pass clipboard HTML directly into dangerouslySetInnerHTML.

Practical use cases and failure modes

Use caseImplementation detailCommon failure
Copy code in docsCopy only the code string and announce successButton label changes cause layout shift
Copy order IDs in admin toolsCopy the ID only, not the whole table rowCustomer names or addresses leak into clipboard
Paste logs into support toolsNormalize, cap size, and scan for secretsTokens or cookies are stored without limits
Share invite linksCopy a time-limited URL and show expiry nearbyExpired links create support tickets

This matters for ClaudeCodeLab products too. Free PDFs, workshop setup commands, and consulting diagnostic prompts work better when readers can copy the exact command or template. But license keys, customer emails, and private purchase data should not be bundled into one convenient copy action.

HTTP, iframes, and mobile Safari caveats

Async Clipboard requires a secure context in modern browsers. HTTPS and localhost are fine; a plain http://192.168.x.x preview may not expose navigator.clipboard at all. Try the fallback, then give manual-copy guidance if both paths fail.

Inside iframes, Chromium-based browsers may require a Permissions Policy or an allow attribute.

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

For Safari and iOS WebKit, keep clipboard calls closely tied to the click or tap. Avoid waiting on fetch, timers, animations, or route changes before calling writeText. Prepare the value before the user clicks, copy immediately inside the handler, then run follow-up UI work. Do not overgeneralize from desktop Chrome; test critical flows on real iOS devices.

Playwright tests for clipboard UX

Grant permissions for the same origin you visit. Playwright notes that permission support differs by browser and browser version, so split tests when needed: Chromium can verify real clipboard content, while WebKit can still verify visible UI feedback.

// 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");
  });
});

When this fails in CI, check the origin first. http://localhost:4173 and http://127.0.0.1:4173 are different origins. Then check whether the browser project supports the permissions you granted and whether translated button labels changed your role query.

Accessibility checklist

  • Use a real button, not a clickable div.
  • Announce success and failure through role="status" and aria-live="polite".
  • Make labels specific: “Copy code”, “Copy invite link”, “Copy order ID”.
  • Keep keyboard focus visible.
  • Give the button a stable minimum width to avoid layout shift.
  • On failure, tell the user what to do next.
  • Do not signal success by color alone.
  • Read clipboard content only after an explicit paste action.

Ask Claude Code to review the risky parts

After implementation, run a narrow review prompt instead of a vague “check this.”

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 uses small Web API features like this in training because they force a complete engineering loop: specification reading, implementation, fallback design, browser testing, accessibility review, and content documentation. For templates and implementation guides, see ClaudeCodeLab products. For team enablement, see Claude Code training.

What happened when we tried it

In Masa’s code-block copy UI, the first issue was visual: the button widened after success and shifted the surrounding code. The second issue appeared on a phone preview served over HTTP, where the async clipboard path was unavailable. The stable version moved copying into a utility, added a textarea fallback and manual-copy guidance, fixed the button width, and made Playwright grant permissions for the exact origin under test. Clipboard looks like a small feature, but it becomes production-ready only when permissions, privacy, accessibility, and tests are designed together.

#Claude Code #Clipboard API #React #Playwright #Accessibility
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.